diff --git a/README.md b/README.md index f51285142..08b3f42bd 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ The result is a local control layer for agentic coding: agents still do the codi Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code. +For direct CLI usage, including agent readiness checks and context-aware session +spawns, see the [CLI Guide](docs/cli/README.md). + **If it runs in a terminal, it runs on Agent Orchestrator.** --- @@ -172,6 +175,7 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc | -------------------------------------------------------- | ------------------------------------------------------- | | [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | | [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | +| [CLI Guide](docs/cli/README.md) | Direct `ao` CLI usage, command routes, and smoke tests | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | --- diff --git a/backend/internal/cli/agent.go b/backend/internal/cli/agent.go new file mode 100644 index 000000000..77c3f7fa1 --- /dev/null +++ b/backend/internal/cli/agent.go @@ -0,0 +1,110 @@ +package cli + +import ( + "fmt" + "sort" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +type agentListOptions struct { + refresh bool + json bool +} + +// agentInfo mirrors the daemon's agent Info body for the CLI client. +type agentInfo struct { + ID string `json:"id"` + Label string `json:"label"` + AuthStatus string `json:"authStatus,omitempty"` +} + +// agentInventory mirrors GET /api/v1/agents and POST /api/v1/agents/refresh. +type agentInventory struct { + Supported []agentInfo `json:"supported"` + Installed []agentInfo `json:"installed"` + Authorized []agentInfo `json:"authorized"` +} + +func newAgentCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Inspect agent catalog readiness", + } + cmd.AddCommand(newAgentListCommand(ctx)) + return cmd +} + +func newAgentListCommand(ctx *commandContext) *cobra.Command { + var opts agentListOptions + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List supported agents and local auth readiness", + Args: noArgs, + RunE: func(cmd *cobra.Command, args []string) error { + inv, err := ctx.fetchAgentInventory(cmd.Context(), opts.refresh) + if err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), inv) + } + return writeAgentList(cmd, inv) + }, + } + cmd.Flags().BoolVar(&opts.refresh, "refresh", false, "Refresh local install/auth probes before listing") + cmd.Flags().BoolVar(&opts.json, "json", false, "Output raw agent catalog JSON") + return cmd +} + +func writeAgentList(cmd *cobra.Command, inv agentInventory) error { + out := cmd.OutOrStdout() + if len(inv.Supported) == 0 { + _, err := fmt.Fprintln(out, "No agents supported by this daemon.") + return err + } + + sort.Slice(inv.Supported, func(i, j int) bool { + return inv.Supported[i].ID < inv.Supported[j].ID + }) + installed := agentInfoByID(inv.Installed) + authorized := agentInfoByID(inv.Authorized) + + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "ID\tLABEL\tINSTALL\tAUTH"); err != nil { + return err + } + for _, info := range inv.Supported { + installLabel := "needs install" + authLabel := "auth unknown" + if installedInfo, ok := installed[info.ID]; ok { + installLabel = "installed" + switch installedInfo.AuthStatus { + case "authorized": + authLabel = "authorized" + case "unauthorized": + authLabel = "needs auth" + default: + authLabel = "auth unknown" + } + } + if _, ok := authorized[info.ID]; ok { + installLabel = "installed" + authLabel = "authorized" + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, info.Label, installLabel, authLabel); err != nil { + return err + } + } + return tw.Flush() +} + +func agentInfoByID(infos []agentInfo) map[string]agentInfo { + out := make(map[string]agentInfo, len(infos)) + for _, info := range infos { + out[info.ID] = info + } + return out +} diff --git a/backend/internal/cli/agent_test.go b/backend/internal/cli/agent_test.go new file mode 100644 index 000000000..eb2426ad5 --- /dev/null +++ b/backend/internal/cli/agent_test.go @@ -0,0 +1,95 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestAgentListUsesCachedCatalogByDefault(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" { + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls") + if err != nil { + t.Fatalf("agent ls failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(out, "codex") || !strings.Contains(out, "needs install") { + t.Fatalf("output missing table labels:\n%s", out) + } + want := []string{"GET /api/v1/agents"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestAgentListRefreshAndStatuses(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh" { + _, _ = io.WriteString(w, `{"supported":[{"id":"aider","label":"Aider"},{"id":"codex","label":"Codex"},{"id":"goose","label":"Goose"},{"id":"opencode","label":"OpenCode"}],"installed":[{"id":"aider","label":"Aider","authStatus":"unauthorized"},{"id":"codex","label":"Codex","authStatus":"authorized"},{"id":"goose","label":"Goose","authStatus":"unknown"}],"authorized":[{"id":"codex","label":"Codex","authStatus":"authorized"}]}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--refresh") + if err != nil { + t.Fatalf("agent ls --refresh failed: %v stderr=%s", err, errOut) + } + for _, want := range []string{"codex", "authorized", "aider", "needs auth", "goose", "auth unknown", "opencode", "needs install"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + want := []string{"POST /api/v1/agents/refresh"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestAgentListJSONEmitsRawCatalog(t *testing.T) { + cfg := setConfigEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" { + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--json") + if err != nil { + t.Fatalf("agent ls --json failed: %v stderr=%s", err, errOut) + } + var inv agentInventory + if err := json.Unmarshal([]byte(out), &inv); err != nil { + t.Fatalf("json output did not decode: %v\n%s", err, out) + } + if len(inv.Supported) != 1 || len(inv.Installed) != 1 || len(inv.Authorized) != 1 { + t.Fatalf("inventory = %#v", inv) + } +} diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index 8e8698ae6..2c96cb8f9 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -27,6 +27,18 @@ type apiError struct { RequestID string `json:"requestId"` } +type apiResponseError struct { + StatusCode int + ErrorBody apiError +} + +func (e apiResponseError) Error() string { + if e.ErrorBody.Message == "" { + return fmt.Sprintf("daemon returned HTTP %d", e.StatusCode) + } + return e.ErrorBody.String() +} + // String renders the envelope for the user: " () [request ]", // omitting whichever parts the daemon left empty. func (e apiError) String() string { @@ -128,10 +140,7 @@ func (c *commandContext) doJSONPath(ctx context.Context, method, path string, bo if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e apiError _ = json.NewDecoder(resp.Body).Decode(&e) - if e.Message == "" { - return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode) - } - return fmt.Errorf("%s", e.String()) + return apiResponseError{StatusCode: resp.StatusCode, ErrorBody: e} } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 743a398d7..68c37b5f2 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -37,6 +37,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -106,6 +107,32 @@ func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string, return sessionsvc.ClaimPRResult{}, nil } +type fakeAgentCatalog struct{} + +var _ controllers.AgentCatalog = (*fakeAgentCatalog)(nil) + +func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { + return authorizedCodexInventory(), nil +} + +func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) { + return authorizedCodexInventory(), nil +} + +func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) { + info := agentsvc.Info{ID: agentID, Label: agentID, AuthStatus: "authorized"} + return agentsvc.ProbeResult{Agent: info, Supported: true, Installed: true}, nil +} + +func authorizedCodexInventory() agentsvc.Inventory { + info := agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"} + return agentsvc.Inventory{ + Supported: []agentsvc.Info{info}, + Installed: []agentsvc.Info{info}, + Authorized: []agentsvc.Info{info}, + } +} + // fakeProjectManager captures the project.AddInput the controller decodes from // the CLI's request body. Every other method is a no-op so it satisfies the // projectsvc.Manager interface. @@ -119,8 +146,9 @@ func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error) return nil, nil } -func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { - return projectsvc.GetResult{}, nil +func (f *fakeProjectManager) Get(_ context.Context, id domain.ProjectID) (projectsvc.GetResult, error) { + project := projectsvc.Project{ID: id, Path: "/repo/" + string(id)} + return projectsvc.GetResult{Status: "ok", Project: &project}, nil } func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) { @@ -150,6 +178,7 @@ func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, pro log := slog.New(slog.NewTextHandler(io.Discard, nil)) router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: &fakeAgentCatalog{}, Sessions: sessions, Projects: projects, }, httpd.ControlDeps{}) diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 0984d4b33..7173d9251 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -184,6 +184,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newStopCommand(ctx)) root.AddCommand(newStatusCommand(ctx)) root.AddCommand(newDoctorCommand(ctx)) + root.AddCommand(newAgentCommand(ctx)) root.AddCommand(newSpawnCommand(ctx)) root.AddCommand(newSendCommand(ctx)) root.AddCommand(newPreviewCommand(ctx)) diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index 920b2b363..4445dde59 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -2,9 +2,14 @@ package cli import ( "context" + "errors" "fmt" + "net/http" "net/url" + "os" + "path/filepath" "runtime" + "sort" "strings" "unicode/utf8" @@ -19,14 +24,15 @@ import ( const maxDisplayNameLen = 20 type spawnOptions struct { - project string - harness string - branch string - prompt string - issue string - name string - claimPR string - noTakeover bool + project string + harness string + branch string + prompt string + issue string + name string + claimPR string + noTakeover bool + skipAgentCheck bool } // spawnRequest mirrors the daemon's SpawnSessionRequest body for @@ -37,7 +43,7 @@ type spawnRequest struct { Harness string `json:"harness,omitempty"` Branch string `json:"branch,omitempty"` Prompt string `json:"prompt,omitempty"` - DisplayName string `json:"displayName"` + DisplayName string `json:"displayName,omitempty"` } type spawnResult struct { @@ -47,6 +53,12 @@ type spawnResult struct { } `json:"session"` } +type agentProbeResult struct { + Agent agentInfo `json:"agent"` + Supported bool `json:"supported"` + Installed bool `json:"installed"` +} + func newSpawnCommand(ctx *commandContext) *cobra.Command { var opts spawnOptions cmd := &cobra.Command{ @@ -57,25 +69,33 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { "fresh git worktree. Register the project first with `ao project add`.", Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { - if opts.project == "" { - return usageError{fmt.Errorf("--project is required")} - } - name := strings.TrimSpace(opts.name) - if name == "" { - return usageError{fmt.Errorf("--name is required")} - } - if utf8.RuneCountInString(name) > maxDisplayNameLen { - return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)} - } if opts.noTakeover && opts.claimPR == "" { return usageError{fmt.Errorf("--no-takeover requires --claim-pr")} } - claimRef := "" - if opts.claimPR != "" { - project, err := ctx.fetchProjectDetails(cmd.Context(), opts.project) - if err != nil { + if explicitName := strings.TrimSpace(opts.name); utf8.RuneCountInString(explicitName) > maxDisplayNameLen { + return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)} + } + + project, err := ctx.resolveSpawnProject(cmd.Context(), opts.project) + if err != nil { + return err + } + opts.project = project.ID + + harness, err := resolveSpawnHarness(opts.harness, project) + if err != nil { + return err + } + opts.harness = harness + + name := resolveSpawnDisplayName(opts.name, opts.prompt) + if !opts.skipAgentCheck { + if err := ctx.preflightSpawnAgentAuth(cmd.Context(), cmd, opts.harness); err != nil { return err } + } + claimRef := "" + if opts.claimPR != "" { claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project) if err != nil { return err @@ -123,7 +143,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } else { attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)" } - _, err := fmt.Fprintf(out, "attach with: %s\n", attach) + _, err = fmt.Fprintf(out, "attach with: %s\n", attach) return err }, } @@ -136,17 +156,274 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } return pflag.NormalizedName(name) }) - f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)") + f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (default: AO_PROJECT_ID or current registered repo)") f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)") f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao//root)") f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") - f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)") + f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (default: derived from --prompt, max 20 characters)") f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session") f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)") + f.BoolVar(&opts.skipAgentCheck, "skip-agent-check", false, "Skip advisory agent catalog install/auth preflight before spawning") return cmd } +func (c *commandContext) fetchAgentInventory(ctx context.Context, refresh bool) (agentInventory, error) { + var inv agentInventory + if refresh { + if err := c.postJSON(ctx, "agents/refresh", struct{}{}, &inv); err != nil { + return agentInventory{}, err + } + return inv, nil + } + if err := c.getJSON(ctx, "agents", &inv); err != nil { + return agentInventory{}, err + } + return inv, nil +} + +func (c *commandContext) resolveSpawnProject(ctx context.Context, explicit string) (projectDetails, error) { + if id := strings.TrimSpace(explicit); id != "" { + return c.fetchProjectDetails(ctx, id) + } + if id := strings.TrimSpace(os.Getenv("AO_PROJECT_ID")); id != "" { + return c.fetchProjectDetails(ctx, id) + } + if sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID")); sessionID != "" { + project, err := c.resolveProjectFromSession(ctx, sessionID) + if err != nil { + return projectDetails{}, err + } + return project, nil + } + project, ok, err := c.resolveProjectFromCWD(ctx) + if err != nil { + return projectDetails{}, err + } + if ok { + return project, nil + } + return projectDetails{}, usageError{fmt.Errorf("project could not be resolved; pass --project or run `ao project add --path --worker-agent `")} +} + +func (c *commandContext) resolveProjectFromSession(ctx context.Context, sessionID string) (projectDetails, error) { + sess, err := c.fetchScopedSession(ctx, sessionID, "") + if err != nil { + return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)} + } + if strings.TrimSpace(sess.ProjectID) == "" { + return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)} + } + return c.fetchProjectDetails(ctx, sess.ProjectID) +} + +func (c *commandContext) resolveProjectFromCWD(ctx context.Context) (projectDetails, bool, error) { + cwd, err := os.Getwd() + if err != nil { + return projectDetails{}, false, err + } + cwd, err = normalizeProjectMatchPath(cwd) + if err != nil { + return projectDetails{}, false, err + } + + var list projectListResult + if err := c.getJSON(ctx, "projects", &list); err != nil { + return projectDetails{}, false, err + } + sort.Slice(list.Projects, func(i, j int) bool { + return list.Projects[i].ID < list.Projects[j].ID + }) + + var best projectDetails + bestLen := -1 + ambiguous := false + for _, summary := range list.Projects { + project, err := c.fetchProjectDetails(ctx, summary.ID) + if err != nil { + return projectDetails{}, false, err + } + if project.Path == "" { + continue + } + projectPath, err := normalizeProjectMatchPath(project.Path) + if err != nil { + continue + } + if !pathContains(projectPath, cwd) { + continue + } + pathLen := len(projectPath) + switch { + case pathLen > bestLen: + best = project + bestLen = pathLen + ambiguous = false + case pathLen == bestLen: + ambiguous = true + } + } + if bestLen == -1 { + return projectDetails{}, false, nil + } + if ambiguous { + return projectDetails{}, false, usageError{fmt.Errorf("current directory matches multiple registered projects; pass --project")} + } + return best, true, nil +} + +func normalizeProjectMatchPath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + if realPath, err := filepath.EvalSymlinks(abs); err == nil { + abs = realPath + } + return filepath.Clean(abs), nil +} + +func pathContains(root, child string) bool { + if root == child { + return true + } + rel, err := filepath.Rel(root, child) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func resolveSpawnHarness(explicit string, project projectDetails) (string, error) { + if harness := strings.TrimSpace(explicit); harness != "" { + return harness, nil + } + if project.Config != nil { + if harness := strings.TrimSpace(project.Config.Worker.Agent); harness != "" { + return harness, nil + } + } + return "", usageError{fmt.Errorf("agent could not be resolved; pass --agent or configure `ao project set-config %s --worker-agent `", project.ID)} +} + +func resolveSpawnDisplayName(explicit, prompt string) string { + if name := strings.TrimSpace(explicit); name != "" { + return name + } + return deriveDisplayNameFromPrompt(prompt) +} + +func deriveDisplayNameFromPrompt(prompt string) string { + fields := strings.Fields(strings.TrimSpace(prompt)) + if len(fields) == 0 { + return "" + } + var b strings.Builder + for _, field := range fields { + next := strings.Trim(field, " \t\r\n.,;:!?()[]{}\"'") + if next == "" { + continue + } + if b.Len() > 0 { + next = " " + next + } + if utf8.RuneCountInString(b.String()+next) > maxDisplayNameLen { + break + } + b.WriteString(next) + } + return b.String() +} + +func (c *commandContext) preflightSpawnAgentAuth(ctx context.Context, cmd *cobra.Command, agentID string) error { + inv, err := c.fetchAgentInventory(ctx, true) + if err != nil { + return err + } + state := agentCatalogStateFor(inv, agentID) + if !state.supported { + return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID) + } + if !state.installed || state.authStatus == "unauthorized" { + fresh, err := c.probeSpawnAgent(ctx, agentID) + if err != nil { + if agentProbeUnavailable(err) { + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q fresh readiness probe is unavailable; continuing and letting spawn validate runtime readiness\n", agentID) + return err + } + return err + } + if !fresh.Supported { + return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID) + } + if !fresh.Installed { + return fmt.Errorf("agent %q needs install; install the agent CLI or pass --skip-agent-check to let spawn validate it", agentID) + } + state.installed = true + state.authorized = fresh.Agent.AuthStatus == "authorized" + state.authStatus = fresh.Agent.AuthStatus + } + if state.authorized { + return nil + } + if state.authStatus == "unauthorized" { + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q may need auth according to a fresh local probe; continuing and letting spawn validate runtime readiness\n", agentID) + return err + } + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q auth status is unknown; continuing and letting spawn validate runtime readiness\n", agentID) + return err +} + +func (c *commandContext) probeSpawnAgent(ctx context.Context, agentID string) (agentProbeResult, error) { + var result agentProbeResult + if err := c.postJSON(ctx, "agents/"+url.PathEscape(agentID)+"/probe", struct{}{}, &result); err != nil { + return agentProbeResult{}, err + } + return result, nil +} + +func agentProbeUnavailable(err error) bool { + var apiErr apiResponseError + if !errors.As(err, &apiErr) { + return false + } + return apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusNotImplemented +} + +type agentCatalogState struct { + supported bool + installed bool + authorized bool + authStatus string +} + +func agentCatalogStateFor(inv agentInventory, agentID string) agentCatalogState { + state := agentCatalogState{} + for _, info := range inv.Supported { + if info.ID == agentID { + state.supported = true + break + } + } + for _, info := range inv.Authorized { + if info.ID == agentID { + state.installed = true + state.authorized = true + state.authStatus = "authorized" + return state + } + } + for _, info := range inv.Installed { + if info.ID == agentID { + state.installed = true + state.authorized = info.AuthStatus == "authorized" + state.authStatus = info.AuthStatus + return state + } + } + return state +} + // rollbackSpawnedSession reverses a partial `spawn` whose out-of-band follow-up // (PR claim) failed. It calls the daemon's `/rollback` endpoint, which deletes // the seed-state row outright instead of marking it terminated — so the user diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index 68bf78b44..486077f86 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -6,23 +6,44 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "strings" "testing" ) -// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing -// --project before touching the network, so it fails fast without a daemon. -func TestSpawnCommand_RequiresProject(t *testing.T) { - var out, errb bytes.Buffer - root := NewRootCommand(Deps{Out: &out, Err: &errb}) - root.SetArgs([]string{"spawn"}) - err := root.Execute() +func authorizedAgentsJSON(agent string) string { + info := `{"id":` + jsonQuote(agent) + `,"label":` + jsonQuote(agent) + `,"authStatus":"authorized"}` + return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}` +} + +// TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project +// setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one. +func TestSpawnCommand_MissingProjectContext(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects" { + _, _ = io.WriteString(w, `{"projects":[]}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex") if err == nil { - t.Fatal("expected an error when --project is missing") + t.Fatal("expected an error when project context is missing") } - if !strings.Contains(err.Error(), "--project is required") { - t.Fatalf("error = %v, want it to mention --project is required", err) + if !strings.Contains(err.Error(), "ao project add --path --worker-agent ") { + t.Fatalf("error = %v, want project add hint", err) + } + if want := []string{"GET /api/v1/projects"}; !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) } } @@ -50,6 +71,8 @@ func TestSpawnClaimPRWiring(t *testing.T) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": _, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-9/pr/claim": @@ -66,14 +89,14 @@ func TestSpawnClaimPRWiring(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover") + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142", "--no-takeover") if err != nil { t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut) } if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") { t.Fatalf("output missing claimed label: %s", out) } - want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"} + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"} if !reflect.DeepEqual(requests, want) { t.Fatalf("requests=%#v want %#v", requests, want) } @@ -89,6 +112,8 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": sessions["demo-10"] = true _, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`) @@ -108,7 +133,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142") + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142") if err == nil { t.Fatal("expected spawn claim failure") } @@ -119,7 +144,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { if sessions["demo-10"] { t.Fatalf("spawned session still present after claim rollback: %#v", sessions) } - want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"} + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"} if !reflect.DeepEqual(requests, want) { t.Fatalf("requests=%#v want %#v", requests, want) } @@ -132,15 +157,6 @@ func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) { } } -// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name -// before touching the network, mirroring the --project guard. -func TestSpawnCommand_RequiresName(t *testing.T) { - _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo") - if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") { - t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err)) - } -} - // TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name // longer than 20 characters without contacting the daemon. func TestSpawnCommand_RejectsOverlongName(t *testing.T) { @@ -149,3 +165,529 @@ func TestSpawnCommand_RejectsOverlongName(t *testing.T) { t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err)) } } + +func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + t.Setenv("AO_PROJECT_ID", "demo") + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix failing tests in auth") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(out, "spawned session demo-11") { + t.Fatalf("output missing spawn: %s", out) + } + if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "Fix failing tests in" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1": + _, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-15","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + t.Setenv("AO_SESSION_ID", "demo-1") + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/sessions/demo-1", "GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/missing": + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"not_found","code":"SESSION_NOT_FOUND","message":"Session not found"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + t.Setenv("AO_SESSION_ID", "missing") + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex") + if err == nil || !strings.Contains(err.Error(), `project could not be resolved from AO_SESSION_ID "missing"; pass --project`) { + t.Fatalf("err=%v, want AO_SESSION_ID project error", err) + } + want := []string{"GET /api/v1/sessions/missing"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnResolvesProjectFromCWD(t *testing.T) { + cfg := setConfigEnv(t) + repo := filepath.Join(t.TempDir(), "repo") + subdir := filepath.Join(repo, "pkg") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + oldwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(subdir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldwd) }) + + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects": + _, _ = io.WriteString(w, `{"projects":[{"id":"demo","name":"Demo"}]}`) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":`+jsonQuote(repo)+`,"config":{"worker":{"agent":"codex"}}}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } +} + +func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"authorized"},"supported":true,"installed":true}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if errOut != "" { + t.Fatalf("stderr = %q, want no warning after fresh authorized probe", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unauthorized"},"supported":true,"installed":true}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "may need auth according to a fresh local probe") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + w.WriteHeader(http.StatusNotImplemented) + _, _ = io.WriteString(w, `{"message":"not implemented","code":"NOT_IMPLEMENTED"}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "fresh readiness probe is unavailable") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unknown") + if err == nil || !strings.Contains(err.Error(), "agent \"unknown\" is not supported") { + t.Fatalf("err=%v, want unsupported", err) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex"},"supported":true,"installed":false}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err == nil || !strings.Contains(err.Error(), "agent \"codex\" needs install") { + t.Fatalf("err=%v, want needs install", err) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unknown"},"supported":true,"installed":true}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "auth status is unknown") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"message":"not found","code":"NOT_FOUND"}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "fresh readiness probe is unavailable") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"message":"probe failed","code":"PROBE_FAILED","requestId":"req-1"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err == nil || !strings.Contains(err.Error(), "probe failed (PROBE_FAILED) [request req-1]") { + t.Fatalf("err=%v, want probe server error", err) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-14","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unsupported", "--skip-agent-check") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if req.ProjectID != "demo" || req.Harness != "unsupported" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unknown"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-13","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "auth status is unknown") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } +} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 727e0a86f..0e31f0639 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -132,10 +132,16 @@ func Run() error { } lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log) previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) + agentSvc := agentsvc.New() + go func() { + if _, err := agentSvc.Refresh(ctx); err != nil { + log.Warn("initial agent catalog refresh failed", "err", err) + } + }() srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}), - Agents: agentsvc.New(), + Agents: agentSvc, Sessions: sessionSvc, Reviews: reviewSvc, Notifications: notifier, diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2551c32ee..e4554bde8 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -33,6 +33,45 @@ paths: summary: Return cached supported and locally installed agent adapters tags: - agents + /api/v1/agents/{agent}/probe: + post: + operationId: probeAgent + parameters: + - description: Agent adapter identifier. + in: path + name: agent + required: true + schema: + description: Agent adapter identifier. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ProbeAgentResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Run a fresh local readiness probe for one agent adapter + tags: + - agents /api/v1/agents/refresh: post: operationId: refreshAgents @@ -1975,6 +2014,19 @@ components: - targetSha - status type: object + ProbeAgentResponse: + properties: + agent: + $ref: '#/components/schemas/AgentInfo' + installed: + type: boolean + supported: + type: boolean + required: + - agent + - supported + - installed + type: object Project: properties: agent: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 9e3d0634a..3ce0d5936 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -137,6 +137,7 @@ var schemaNames = map[string]string{ // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", + "ControllersAgentIDParam": "AgentIDParam", "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", "ControllersListSessionsQuery": "ListSessionsQuery", @@ -174,6 +175,7 @@ var schemaNames = map[string]string{ "ControllersOrchestratorResponse": "OrchestratorResponse", "AgentInventory": "ListAgentsResponse", "AgentInfo": "AgentInfo", + "AgentProbeResult": "ProbeAgentResponse", "ControllersListNotificationsQuery": "ListNotificationsQuery", "ControllersNotificationStreamQuery": "NotificationStreamQuery", "ControllersNotificationIDParam": "NotificationIDParam", @@ -313,6 +315,17 @@ func agentOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, + { + method: http.MethodPost, path: "/api/v1/agents/{agent}/probe", id: "probeAgent", tag: "agents", + summary: "Run a fresh local readiness probe for one agent adapter", + pathParams: []any{controllers.AgentIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.ProbeAgentResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, } } diff --git a/backend/internal/httpd/controllers/agents.go b/backend/internal/httpd/controllers/agents.go index d8c3624e3..30602a5b4 100644 --- a/backend/internal/httpd/controllers/agents.go +++ b/backend/internal/httpd/controllers/agents.go @@ -3,6 +3,7 @@ package controllers import ( "context" "net/http" + "strings" "github.com/go-chi/chi/v5" @@ -15,6 +16,7 @@ import ( type AgentCatalog interface { List(ctx context.Context) (agentsvc.Inventory, error) Refresh(ctx context.Context) (agentsvc.Inventory, error) + Probe(ctx context.Context, agentID string) (agentsvc.ProbeResult, error) } // AgentsController owns the /agents routes. @@ -26,6 +28,7 @@ type AgentsController struct { func (c *AgentsController) Register(r chi.Router) { r.Get("/agents", c.list) r.Post("/agents/refresh", c.refresh) + r.Post("/agents/{agent}/probe", c.probe) } func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) { @@ -53,3 +56,21 @@ func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) { } envelope.WriteJSON(w, http.StatusOK, inventory) } + +func (c *AgentsController) probe(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/agents/{agent}/probe") + return + } + agentID := strings.TrimSpace(chi.URLParam(r, "agent")) + if agentID == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "AGENT_REQUIRED", "agent is required", nil) + return + } + result, err := c.Catalog.Probe(r.Context(), agentID) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, result) +} diff --git a/backend/internal/httpd/controllers/agents_test.go b/backend/internal/httpd/controllers/agents_test.go index 76e0d0545..1304f21ca 100644 --- a/backend/internal/httpd/controllers/agents_test.go +++ b/backend/internal/httpd/controllers/agents_test.go @@ -17,9 +17,12 @@ import ( type fakeAgentCatalog struct { inventory agentsvc.Inventory refreshed agentsvc.Inventory + probed agentsvc.ProbeResult err error listCalls int refreshCalls int + probeCalls int + probeAgent string } func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { @@ -35,6 +38,12 @@ func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) return f.inventory, f.err } +func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) { + f.probeCalls++ + f.probeAgent = agentID + return f.probed, f.err +} + func TestListAgents(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{ @@ -92,3 +101,31 @@ func TestRefreshAgents(t *testing.T) { t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls) } } + +func TestProbeAgent(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{ + probed: agentsvc.ProbeResult{ + Agent: agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"}, + Supported: true, + Installed: true, + }, + } + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/codex/probe", "") + if status != http.StatusOK { + t.Fatalf("POST /agents/codex/probe = %d, body=%s", status, body) + } + for _, want := range []string{`"supported":true`, `"installed":true`, `"id":"codex"`, `"authStatus":"authorized"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if catalog.probeCalls != 1 || catalog.probeAgent != "codex" { + t.Fatalf("probe calls=%d agent=%q, want one codex probe", catalog.probeCalls, catalog.probeAgent) + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 0f830b244..89cad9192 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -27,6 +27,11 @@ type ProjectIDParam struct { ID string `path:"id" description:"Project identifier (registry key)."` } +// AgentIDParam is the {agent} path parameter for one-agent catalog probes. +type AgentIDParam struct { + Agent string `path:"agent" description:"Agent adapter identifier."` +} + // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { Projects []projectsvc.Summary `json:"projects"` @@ -442,6 +447,9 @@ type ListAgentsResponse = agentsvc.Inventory // RefreshAgentsResponse is the body of POST /api/v1/agents/refresh. type RefreshAgentsResponse = agentsvc.Inventory +// ProbeAgentResponse is the body of POST /api/v1/agents/{agent}/probe. +type ProbeAgentResponse = agentsvc.ProbeResult + // AgentInfo is one supported or installed agent entry. type AgentInfo = agentsvc.Info diff --git a/backend/internal/service/agent/catalog_test.go b/backend/internal/service/agent/catalog_test.go index 2df062e3c..9b238a2ce 100644 --- a/backend/internal/service/agent/catalog_test.go +++ b/backend/internal/service/agent/catalog_test.go @@ -280,6 +280,61 @@ func TestRefreshIsRateLimited(t *testing.T) { } } +func TestProbeBypassesRefreshRateLimitForOneAgent(t *testing.T) { + previous := agentRefreshMinInterval + agentRefreshMinInterval = time.Hour + t.Cleanup(func() { agentRefreshMinInterval = previous }) + + probes := 0 + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{fakeAgent: fakeAgent{}, onProbe: func() { probes++ }}, + }, + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + }) + + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh: %v", err) + } + got, err := svc.Probe(context.Background(), "codex") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if !got.Supported || !got.Installed || got.Agent.ID != "codex" { + t.Fatalf("Probe = %#v, want supported installed codex", got) + } + if probes != 2 { + t.Fatalf("probes = %d, want refresh plus fresh probe", probes) + } +} + +func TestProbeReportsUnsupportedAndMissingAgent(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + }) + + missing, err := svc.Probe(context.Background(), "missing") + if err != nil { + t.Fatalf("Probe missing: %v", err) + } + if !missing.Supported || missing.Installed { + t.Fatalf("Probe missing = %#v, want supported but not installed", missing) + } + + unsupported, err := svc.Probe(context.Background(), "unknown") + if err != nil { + t.Fatalf("Probe unknown: %v", err) + } + if unsupported.Supported || unsupported.Installed || unsupported.Agent.ID != "unknown" { + t.Fatalf("Probe unknown = %#v, want unsupported unknown", unsupported) + } +} + func harnessAgent(id, label string, err error) agentregistry.HarnessAgent { return agentregistry.HarnessAgent{ Harness: domain.AgentHarness(id), diff --git a/backend/internal/service/agent/service.go b/backend/internal/service/agent/service.go index 2a2391294..837ddee17 100644 --- a/backend/internal/service/agent/service.go +++ b/backend/internal/service/agent/service.go @@ -23,6 +23,13 @@ type probeResult struct { authorized bool } +// ProbeResult describes a fresh readiness probe for one supported agent. +type ProbeResult struct { + Agent Info `json:"agent"` + Supported bool `json:"supported"` + Installed bool `json:"installed"` +} + // Info is the user-facing identity for an agent adapter. type Info struct { ID string `json:"id"` @@ -140,6 +147,31 @@ func (s *Service) Refresh(ctx context.Context) (Inventory, error) { return next, nil } +// Probe runs a fresh bounded binary/auth probe for one agent, bypassing the +// catalog refresh rate limit. It is intended for user-initiated preflight paths +// where a cached negative catalog result may be stale. +func (s *Service) Probe(ctx context.Context, agentID string) (ProbeResult, error) { + if err := ctx.Err(); err != nil { + return ProbeResult{}, err + } + for _, item := range s.agents { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + if info.ID != agentID { + continue + } + res := probeAgent(ctx, item) + return ProbeResult{ + Agent: res.info, + Supported: true, + Installed: res.installed, + }, nil + } + return ProbeResult{Agent: Info{ID: agentID}, Supported: false, Installed: false}, nil +} + func supportedInfos(agents []agentregistry.HarnessAgent) []Info { supported := make([]Info, 0, len(agents)) for _, item := range agents { diff --git a/docs/cli/README.md b/docs/cli/README.md index b26f542b6..a94952ff6 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -5,6 +5,18 @@ It starts, discovers, inspects, and stops the daemon through the loopback HTTP surface and the `running.json` handshake. It must not open SQLite directly or call runtime, workspace, tracker, or agent adapters in-process. +When using the CLI directly from a shell, make sure the daemon is running first +with `ao start` or by opening the desktop app. Product commands such as +`ao agent ls` and `ao spawn` call the loopback daemon and will fail with a +"daemon is not running" error if no `running.json` points at a live process. From +a source checkout, build and run the local binary explicitly, for example: + +```bash +cd backend +go build -o ./bin/ao ./cmd/ao +./bin/ao agent ls +``` + ## Current commands Every product command resolves to a daemon HTTP route. Run `ao @@ -31,6 +43,8 @@ Every product command resolves to a daemon HTTP route. Run `ao | `ao project get ` | `GET /api/v1/projects/{id}` | | `ao project set-config ` | `PUT /api/v1/projects/{id}/config` | | `ao project rm ` | `DELETE /api/v1/projects/{id}` | +| `ao agent ls` | `GET /api/v1/agents` | +| `ao agent ls --refresh` | `POST /api/v1/agents/refresh` | | `ao spawn` | `POST /api/v1/sessions` | | `ao session ls` | `GET /api/v1/sessions` | | `ao session get ` | `GET /api/v1/sessions/{id}` | @@ -44,6 +58,23 @@ Every product command resolves to a daemon HTTP route. Run `ao | `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` | | `ao hooks ` | `POST /api/v1/sessions/{id}/activity` (hidden) | +`ao agent ls` prints the daemon-supported agent catalog with local install/auth +readiness. Use `--refresh` to rerun the bounded local probes and `--json` to +print the raw inventory response. + +`ao spawn` resolves project context in this order: explicit `--project`, +`AO_PROJECT_ID`, `AO_SESSION_ID` (by fetching the current session from the +daemon), then the current working directory matched against registered project +paths. If `AO_SESSION_ID` is set but the session cannot be fetched, pass +`--project` explicitly. + +If `--agent` / `--harness` is omitted, `ao spawn` uses the resolved project's +`worker.agent` config. Before spawning, the CLI refreshes the advisory agent +catalog and fails early when the selected agent is unsupported, not installed, +or unauthorized. It warns-but-continues when auth remains unknown because daemon +spawn remains the authoritative runtime validation point. Use +`--skip-agent-check` to bypass only this CLI-side preflight. + `ao preview` resolves its session from the `AO_SESSION_ID` environment variable (it is meant to run inside a session), not a flag. With no argument it autodetects an `index.html` in the session workspace; with a URL argument it diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 294de0c00..3bd45ec04 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -21,6 +21,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/agents/{agent}/probe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Run a fresh local readiness probe for one agent adapter */ + post: operations["probeAgent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/agents/refresh": { parameters: { query?: never; @@ -708,6 +725,11 @@ export interface components { targetSha: string; title: string; }; + ProbeAgentResponse: { + agent: components["schemas"]["AgentInfo"]; + installed: boolean; + supported: boolean; + }; Project: { agent?: string; config?: components["schemas"]["ProjectConfig"]; @@ -1027,6 +1049,56 @@ export interface operations { }; }; }; + probeAgent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Agent adapter identifier. */ + agent: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProbeAgentResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; refreshAgents: { parameters: { query?: never;