feat: add picoclaw doctor command for diagnosing common problems
Checks workspace structure, config validation, session integrity (orphan tool_calls, missing tool_results), and auth credential health. Supports --fix flag for auto-repair (delete corrupt sessions, fix permissions, refresh expired tokens).
This commit is contained in:
parent
9453606f9c
commit
8c87c73e7d
4 changed files with 725 additions and 0 deletions
112
cmd/picoclaw/cmd_doctor.go
Normal file
112
cmd/picoclaw/cmd_doctor.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/doctor"
|
||||
)
|
||||
|
||||
func doctorCmd() {
|
||||
fix := false
|
||||
for _, arg := range os.Args[2:] {
|
||||
switch arg {
|
||||
case "--fix", "-fix":
|
||||
fix = true
|
||||
case "--help", "-h":
|
||||
fmt.Println("Usage: picoclaw doctor [--fix]")
|
||||
fmt.Println()
|
||||
fmt.Println("Checks for common problems:")
|
||||
fmt.Println(" - Workspace structure (config dir, workspace dir, permissions)")
|
||||
fmt.Println(" - Config validation (model_list, default model, provider prefixes)")
|
||||
fmt.Println(" - Session integrity (orphan tool_calls, missing tool_results)")
|
||||
fmt.Println(" - Auth credentials (expired tokens, reachability)")
|
||||
fmt.Println()
|
||||
fmt.Println("Flags:")
|
||||
fmt.Println(" --fix Attempt to automatically fix problems")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%s picoclaw doctor\n\n", logo)
|
||||
|
||||
opts := doctor.Options{
|
||||
Fix: fix,
|
||||
}
|
||||
|
||||
findings := doctor.Run(opts)
|
||||
|
||||
// Group findings by check
|
||||
errors := 0
|
||||
warns := 0
|
||||
fixed := 0
|
||||
|
||||
for _, f := range findings {
|
||||
icon := f.Severity.Icon()
|
||||
switch f.Severity {
|
||||
case doctor.SeverityInfo:
|
||||
fmt.Printf(" [%s] %s\n", icon, f.Message)
|
||||
case doctor.SeverityWarn:
|
||||
fmt.Printf(" [%s] %s\n", icon, f.Message)
|
||||
warns++
|
||||
case doctor.SeverityError:
|
||||
fmt.Printf(" [%s] %s\n", icon, f.Message)
|
||||
errors++
|
||||
}
|
||||
|
||||
// Auto-fix if requested and available
|
||||
if fix && f.FixFunc != nil {
|
||||
fmt.Printf(" -> fixing: %s ... ", f.Fix)
|
||||
if err := f.FixFunc(); err != nil {
|
||||
fmt.Printf("FAILED: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("OK\n")
|
||||
fixed++
|
||||
// Downgrade the counts since we fixed it
|
||||
if f.Severity == doctor.SeverityError {
|
||||
errors--
|
||||
} else if f.Severity == doctor.SeverityWarn {
|
||||
warns--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if errors == 0 && warns == 0 {
|
||||
fmt.Printf("%s All checks passed!\n", logo)
|
||||
} else {
|
||||
summary := fmt.Sprintf("%s Found", logo)
|
||||
if errors > 0 {
|
||||
summary += fmt.Sprintf(" %d error(s)", errors)
|
||||
}
|
||||
if warns > 0 {
|
||||
if errors > 0 {
|
||||
summary += " and"
|
||||
}
|
||||
summary += fmt.Sprintf(" %d warning(s)", warns)
|
||||
}
|
||||
if fixed > 0 {
|
||||
summary += fmt.Sprintf(" (%d fixed)", fixed)
|
||||
}
|
||||
fmt.Println(summary)
|
||||
|
||||
// Hint about --fix if there were fixable problems and --fix wasn't used
|
||||
if !fix {
|
||||
hasFixable := false
|
||||
for _, f := range findings {
|
||||
if f.FixFunc != nil {
|
||||
hasFixable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasFixable {
|
||||
fmt.Println(" Run 'picoclaw doctor --fix' to attempt automatic fixes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +115,8 @@ func main() {
|
|||
authCmd()
|
||||
case "cron":
|
||||
cronCmd()
|
||||
case "doctor":
|
||||
doctorCmd()
|
||||
case "skills":
|
||||
if len(os.Args) < 3 {
|
||||
skillsHelp()
|
||||
|
|
@ -186,6 +188,7 @@ func printHelp() {
|
|||
fmt.Println(" cron Manage scheduled tasks")
|
||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
||||
fmt.Println(" doctor Check for common problems (--fix to auto-repair)")
|
||||
fmt.Println(" version Show version information")
|
||||
}
|
||||
|
||||
|
|
|
|||
505
pkg/doctor/doctor.go
Normal file
505
pkg/doctor/doctor.go
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// Severity classifies how bad a problem is.
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
SeverityInfo Severity = iota
|
||||
SeverityWarn
|
||||
SeverityError
|
||||
)
|
||||
|
||||
func (s Severity) String() string {
|
||||
switch s {
|
||||
case SeverityInfo:
|
||||
return "info"
|
||||
case SeverityWarn:
|
||||
return "warn"
|
||||
case SeverityError:
|
||||
return "ERROR"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func (s Severity) Icon() string {
|
||||
switch s {
|
||||
case SeverityInfo:
|
||||
return "i"
|
||||
case SeverityWarn:
|
||||
return "!"
|
||||
case SeverityError:
|
||||
return "x"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
// Finding is a single problem or observation.
|
||||
type Finding struct {
|
||||
Check string
|
||||
Severity Severity
|
||||
Message string
|
||||
Fix string // non-empty if auto-fixable
|
||||
FixFunc func() error
|
||||
}
|
||||
|
||||
// Result is what a check function returns.
|
||||
type Result struct {
|
||||
Findings []Finding
|
||||
}
|
||||
|
||||
func (r *Result) Add(check string, sev Severity, msg string) {
|
||||
r.Findings = append(r.Findings, Finding{Check: check, Severity: sev, Message: msg})
|
||||
}
|
||||
|
||||
func (r *Result) AddFixable(check string, sev Severity, msg, fix string, fn func() error) {
|
||||
r.Findings = append(r.Findings, Finding{Check: check, Severity: sev, Message: msg, Fix: fix, FixFunc: fn})
|
||||
}
|
||||
|
||||
func (r *Result) OK(check, msg string) {
|
||||
r.Add(check, SeverityInfo, msg)
|
||||
}
|
||||
|
||||
func (r *Result) Warn(check, msg string) {
|
||||
r.Add(check, SeverityWarn, msg)
|
||||
}
|
||||
|
||||
func (r *Result) Error(check, msg string) {
|
||||
r.Add(check, SeverityError, msg)
|
||||
}
|
||||
|
||||
// Options controls doctor behavior.
|
||||
type Options struct {
|
||||
Fix bool // attempt auto-fixes
|
||||
ConfigDir string // ~/.picoclaw
|
||||
}
|
||||
|
||||
// Run executes all checks and returns findings.
|
||||
func Run(opts Options) []Finding {
|
||||
if opts.ConfigDir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
opts.ConfigDir = filepath.Join(home, ".picoclaw")
|
||||
}
|
||||
|
||||
var all []Finding
|
||||
checks := []func(Options) Result{
|
||||
checkWorkspace,
|
||||
checkConfig,
|
||||
checkSessions,
|
||||
checkAuth,
|
||||
}
|
||||
for _, check := range checks {
|
||||
r := check(opts)
|
||||
all = append(all, r.Findings...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: workspace structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func checkWorkspace(opts Options) Result {
|
||||
var r Result
|
||||
check := "workspace"
|
||||
|
||||
configDir := opts.ConfigDir
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
r.Error(check, fmt.Sprintf("config directory missing: %s", configDir))
|
||||
return r
|
||||
}
|
||||
r.OK(check, fmt.Sprintf("config directory exists: %s", configDir))
|
||||
|
||||
configFile := filepath.Join(configDir, "config.json")
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
r.Error(check, "config.json missing — run 'picoclaw onboard'")
|
||||
return r
|
||||
}
|
||||
r.OK(check, "config.json exists")
|
||||
|
||||
// Load config to find workspace path
|
||||
cfg, err := config.LoadConfig(configFile)
|
||||
if err != nil {
|
||||
r.Error(check, fmt.Sprintf("config.json parse error: %v", err))
|
||||
return r
|
||||
}
|
||||
|
||||
ws := cfg.WorkspacePath()
|
||||
if _, err := os.Stat(ws); os.IsNotExist(err) {
|
||||
r.Warn(check, fmt.Sprintf("workspace directory missing: %s", ws))
|
||||
} else {
|
||||
r.OK(check, fmt.Sprintf("workspace directory exists: %s", ws))
|
||||
}
|
||||
|
||||
sessionsDir := filepath.Join(ws, "sessions")
|
||||
if _, err := os.Stat(sessionsDir); os.IsNotExist(err) {
|
||||
r.OK(check, "sessions directory does not exist yet (will be created on first use)")
|
||||
} else {
|
||||
r.OK(check, fmt.Sprintf("sessions directory exists: %s", sessionsDir))
|
||||
}
|
||||
|
||||
// Check auth.json permissions
|
||||
authFile := filepath.Join(configDir, "auth.json")
|
||||
if info, err := os.Stat(authFile); err == nil {
|
||||
perm := info.Mode().Perm()
|
||||
if perm&0077 != 0 {
|
||||
r.AddFixable(check, SeverityWarn,
|
||||
fmt.Sprintf("auth.json has loose permissions: %o (should be 600)", perm),
|
||||
"chmod 600 auth.json",
|
||||
func() error { return os.Chmod(authFile, 0600) },
|
||||
)
|
||||
} else {
|
||||
r.OK(check, "auth.json permissions OK (600)")
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: config validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func checkConfig(opts Options) Result {
|
||||
var r Result
|
||||
check := "config"
|
||||
|
||||
configFile := filepath.Join(opts.ConfigDir, "config.json")
|
||||
cfg, err := config.LoadConfig(configFile)
|
||||
if err != nil {
|
||||
r.Error(check, fmt.Sprintf("cannot load config: %v", err))
|
||||
return r
|
||||
}
|
||||
|
||||
defaultModel := cfg.Agents.Defaults.Model
|
||||
if defaultModel == "" {
|
||||
r.Error(check, "no default model configured")
|
||||
return r
|
||||
}
|
||||
r.OK(check, fmt.Sprintf("default model: %s", defaultModel))
|
||||
|
||||
if len(cfg.ModelList) == 0 {
|
||||
r.Error(check, "model_list is empty — no models configured")
|
||||
return r
|
||||
}
|
||||
r.OK(check, fmt.Sprintf("%d model(s) in model_list", len(cfg.ModelList)))
|
||||
|
||||
// Check each model entry
|
||||
foundDefault := false
|
||||
for i, m := range cfg.ModelList {
|
||||
if err := m.Validate(); err != nil {
|
||||
r.Error(check, fmt.Sprintf("model_list[%d] (%s): %v", i, m.ModelName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check provider prefix
|
||||
parts := strings.SplitN(m.Model, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
r.Warn(check, fmt.Sprintf("model_list[%d] (%s): model identifier %q missing provider/ prefix", i, m.ModelName, m.Model))
|
||||
}
|
||||
|
||||
// Check auth: needs either api_key or auth_method
|
||||
if m.APIKey == "" && m.AuthMethod == "" {
|
||||
r.Warn(check, fmt.Sprintf("model_list[%d] (%s): no api_key or auth_method set", i, m.ModelName))
|
||||
}
|
||||
|
||||
if m.ModelName == defaultModel {
|
||||
foundDefault = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDefault {
|
||||
r.Error(check, fmt.Sprintf("default model %q not found in model_list — agent will fail to start", defaultModel))
|
||||
} else {
|
||||
r.OK(check, fmt.Sprintf("default model %q found in model_list", defaultModel))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: session integrity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// sessionFile is the raw JSON structure we load for inspection.
|
||||
type sessionFile struct {
|
||||
Key string `json:"key"`
|
||||
Messages []providers.Message `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
func checkSessions(opts Options) Result {
|
||||
var r Result
|
||||
check := "sessions"
|
||||
|
||||
configFile := filepath.Join(opts.ConfigDir, "config.json")
|
||||
cfg, err := config.LoadConfig(configFile)
|
||||
if err != nil {
|
||||
r.Error(check, fmt.Sprintf("cannot load config to find workspace: %v", err))
|
||||
return r
|
||||
}
|
||||
|
||||
sessionsDir := filepath.Join(cfg.WorkspacePath(), "sessions")
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
r.OK(check, "no sessions directory — nothing to check")
|
||||
return r
|
||||
}
|
||||
r.Error(check, fmt.Sprintf("cannot read sessions directory: %v", err))
|
||||
return r
|
||||
}
|
||||
|
||||
sessionCount := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
sessionCount++
|
||||
|
||||
filePath := filepath.Join(sessionsDir, entry.Name())
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
r.Error(check, fmt.Sprintf("%s: cannot read: %v", entry.Name(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
var sess sessionFile
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
r.Error(check, fmt.Sprintf("%s: invalid JSON: %v", entry.Name(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
problems := checkSessionMessages(sess.Messages)
|
||||
if len(problems) == 0 {
|
||||
r.OK(check, fmt.Sprintf("%s: %d messages, OK", entry.Name(), len(sess.Messages)))
|
||||
} else {
|
||||
for _, p := range problems {
|
||||
r.AddFixable(check, SeverityError,
|
||||
fmt.Sprintf("%s: %s", entry.Name(), p),
|
||||
"remove corrupt session file",
|
||||
makeSessionDeleteFunc(filePath),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sessionCount == 0 {
|
||||
r.OK(check, "no session files found")
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// checkSessionMessages inspects a message array for common corruption patterns.
|
||||
func checkSessionMessages(msgs []providers.Message) []string {
|
||||
var problems []string
|
||||
|
||||
// Build a set of tool_call IDs that have a corresponding tool result.
|
||||
toolResultIDs := map[string]bool{}
|
||||
for _, m := range msgs {
|
||||
if m.Role == "tool" && m.ToolCallID != "" {
|
||||
toolResultIDs[m.ToolCallID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i, m := range msgs {
|
||||
// Check: assistant message with tool_calls must be followed by tool results
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
for _, tc := range m.ToolCalls {
|
||||
id := tc.ID
|
||||
if id == "" {
|
||||
// Use Function.Name for identification
|
||||
name := tc.Name
|
||||
if tc.Function != nil {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("message[%d]: tool_call has empty ID (tool: %s)", i, name))
|
||||
continue
|
||||
}
|
||||
if !toolResultIDs[id] {
|
||||
name := tc.Name
|
||||
if tc.Function != nil {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("message[%d]: orphan tool_call %q (tool: %s) — no matching tool result", i, id, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check: tool result must have a corresponding tool_call
|
||||
if m.Role == "tool" && m.ToolCallID != "" {
|
||||
found := false
|
||||
for _, prev := range msgs[:i] {
|
||||
if prev.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
for _, tc := range prev.ToolCalls {
|
||||
if tc.ID == m.ToolCallID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
problems = append(problems, fmt.Sprintf("message[%d]: orphan tool_result %q — no matching tool_call", i, m.ToolCallID))
|
||||
}
|
||||
}
|
||||
|
||||
// Check: empty content on non-tool messages is suspicious (not always an error)
|
||||
if m.Role == "assistant" && m.Content == "" && len(m.ToolCalls) == 0 {
|
||||
problems = append(problems, fmt.Sprintf("message[%d]: assistant message with empty content and no tool_calls", i))
|
||||
}
|
||||
|
||||
// Check: consecutive same-role messages (user, user) — some providers reject this
|
||||
if i > 0 && m.Role == msgs[i-1].Role && m.Role == "user" {
|
||||
problems = append(problems, fmt.Sprintf("message[%d]: consecutive user messages (some providers reject this)", i))
|
||||
}
|
||||
}
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
func makeSessionDeleteFunc(path string) func() error {
|
||||
return func() error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: auth credential health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func checkAuth(opts Options) Result {
|
||||
var r Result
|
||||
check := "auth"
|
||||
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
r.Error(check, fmt.Sprintf("cannot load auth store: %v", err))
|
||||
return r
|
||||
}
|
||||
|
||||
if len(store.Credentials) == 0 {
|
||||
r.Warn(check, "no credentials stored — run 'picoclaw auth login'")
|
||||
return r
|
||||
}
|
||||
|
||||
r.OK(check, fmt.Sprintf("%d credential(s) found", len(store.Credentials)))
|
||||
|
||||
for provider, cred := range store.Credentials {
|
||||
prefix := fmt.Sprintf("[%s]", provider)
|
||||
|
||||
if cred.AccessToken == "" {
|
||||
r.Error(check, fmt.Sprintf("%s access_token is empty", prefix))
|
||||
continue
|
||||
}
|
||||
|
||||
switch cred.AuthMethod {
|
||||
case "oauth":
|
||||
if cred.ExpiresAt.IsZero() {
|
||||
r.Warn(check, fmt.Sprintf("%s OAuth token has no expiry set", prefix))
|
||||
} else if cred.IsExpired() {
|
||||
if cred.RefreshToken != "" {
|
||||
r.Warn(check, fmt.Sprintf("%s OAuth token expired at %s (refresh token available)", prefix, cred.ExpiresAt.Format(time.RFC3339)))
|
||||
// Try a refresh
|
||||
if provider == "anthropic" {
|
||||
r.AddFixable(check, SeverityWarn,
|
||||
fmt.Sprintf("%s token expired — can attempt refresh", prefix),
|
||||
"refresh Anthropic OAuth token",
|
||||
func() error { return tryRefreshAnthropic(cred) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
r.Error(check, fmt.Sprintf("%s OAuth token expired at %s (no refresh token)", prefix, cred.ExpiresAt.Format(time.RFC3339)))
|
||||
}
|
||||
} else {
|
||||
remaining := time.Until(cred.ExpiresAt).Truncate(time.Minute)
|
||||
r.OK(check, fmt.Sprintf("%s OAuth token valid (expires in %s)", prefix, remaining))
|
||||
|
||||
if cred.NeedsRefresh() {
|
||||
r.Warn(check, fmt.Sprintf("%s token expires within 5 minutes — will need refresh soon", prefix))
|
||||
}
|
||||
}
|
||||
|
||||
if cred.Email != "" {
|
||||
r.OK(check, fmt.Sprintf("%s email: %s", prefix, cred.Email))
|
||||
}
|
||||
if cred.SubscriptionType != "" {
|
||||
r.OK(check, fmt.Sprintf("%s plan: %s", prefix, cred.SubscriptionType))
|
||||
}
|
||||
|
||||
case "token", "":
|
||||
// Paste token — just check it looks non-empty
|
||||
r.OK(check, fmt.Sprintf("%s API key/token present (length %d)", prefix, len(cred.AccessToken)))
|
||||
|
||||
default:
|
||||
r.Warn(check, fmt.Sprintf("%s unknown auth_method: %s", prefix, cred.AuthMethod))
|
||||
}
|
||||
|
||||
// Check: can we actually reach the provider's API?
|
||||
if provider == "anthropic" {
|
||||
checkAnthropicReachable(&r, check, prefix)
|
||||
} else if provider == "openai" {
|
||||
checkOpenAIReachable(&r, check, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// tryRefreshAnthropic attempts to refresh an expired Anthropic OAuth token.
|
||||
func tryRefreshAnthropic(cred *auth.AuthCredential) error {
|
||||
return auth.RefreshAnthropicCredential(cred)
|
||||
}
|
||||
|
||||
func checkAnthropicReachable(r *Result, check, prefix string) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get("https://api.anthropic.com/v1/models")
|
||||
if err != nil {
|
||||
r.Warn(check, fmt.Sprintf("%s cannot reach api.anthropic.com: %v", prefix, err))
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
// 401 is expected without auth — it means the endpoint is reachable
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 200 || resp.StatusCode == 403 {
|
||||
r.OK(check, fmt.Sprintf("%s api.anthropic.com reachable", prefix))
|
||||
} else {
|
||||
r.Warn(check, fmt.Sprintf("%s api.anthropic.com returned unexpected status: %d", prefix, resp.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
func checkOpenAIReachable(r *Result, check, prefix string) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get("https://api.openai.com/v1/models")
|
||||
if err != nil {
|
||||
r.Warn(check, fmt.Sprintf("%s cannot reach api.openai.com: %v", prefix, err))
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 200 || resp.StatusCode == 403 {
|
||||
r.OK(check, fmt.Sprintf("%s api.openai.com reachable", prefix))
|
||||
} else {
|
||||
r.Warn(check, fmt.Sprintf("%s api.openai.com returned unexpected status: %d", prefix, resp.StatusCode))
|
||||
}
|
||||
}
|
||||
105
pkg/doctor/doctor_test.go
Normal file
105
pkg/doctor/doctor_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestCheckSessionMessages_Clean(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "hi there"},
|
||||
}
|
||||
problems := checkSessionMessages(msgs)
|
||||
if len(problems) != 0 {
|
||||
t.Errorf("expected no problems, got %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSessionMessages_OrphanToolCall(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "let me check",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{ID: "call_123", Name: "exec"},
|
||||
},
|
||||
},
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
problems := checkSessionMessages(msgs)
|
||||
if len(problems) != 1 {
|
||||
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
|
||||
}
|
||||
if problems[0] == "" {
|
||||
t.Error("problem message should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSessionMessages_MatchedToolCall(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "let me check",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{ID: "call_123", Name: "exec"},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: "output", ToolCallID: "call_123"},
|
||||
{Role: "assistant", Content: "done"},
|
||||
}
|
||||
problems := checkSessionMessages(msgs)
|
||||
if len(problems) != 0 {
|
||||
t.Errorf("expected no problems, got %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSessionMessages_OrphanToolResult(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "tool", Content: "output", ToolCallID: "call_orphan"},
|
||||
}
|
||||
problems := checkSessionMessages(msgs)
|
||||
if len(problems) != 1 {
|
||||
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSessionMessages_EmptyAssistant(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: ""},
|
||||
}
|
||||
problems := checkSessionMessages(msgs)
|
||||
if len(problems) != 1 {
|
||||
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSessionMessages_ConsecutiveUserMessages(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "user", Content: "hello again"},
|
||||
}
|
||||
problems := checkSessionMessages(msgs)
|
||||
if len(problems) != 1 {
|
||||
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityString(t *testing.T) {
|
||||
tests := []struct {
|
||||
sev Severity
|
||||
want string
|
||||
}{
|
||||
{SeverityInfo, "info"},
|
||||
{SeverityWarn, "warn"},
|
||||
{SeverityError, "ERROR"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.sev.String(); got != tt.want {
|
||||
t.Errorf("Severity(%d).String() = %q, want %q", tt.sev, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue