feat(cli): context-aware agent spawn with agent catalog and auth preflight (#2411)

* feat(cli): add agent command for listing and refreshing agent catalog

- Implemented `ao agent ls` command to list supported agents and their installation/auth readiness.
- Added `--refresh` flag to refresh local install/auth probes before listing agents.
- Introduced JSON output option with `--json` for raw agent catalog response.
- Updated tests to cover new agent command functionality, including cached catalog usage and refresh behavior.
- Enhanced error handling and output formatting for better user experience.

* docs: add CLI Guide link to README for direct usage instructions

* docs: clarify direct CLI usage

* fix: tighten spawn agent preflight

* feat(cli): implement agent readiness probe and update API specifications
This commit is contained in:
NIKHIL ACHALE 2026-07-04 21:23:03 +05:30 committed by GitHub
parent 2c08597ee6
commit 821cda64a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1450 additions and 56 deletions

View File

@ -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. 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.** **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 | | [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 | | [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 | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
--- ---

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -27,6 +27,18 @@ type apiError struct {
RequestID string `json:"requestId"` 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: "<message> (<code>) [request <id>]", // String renders the envelope for the user: "<message> (<code>) [request <id>]",
// omitting whichever parts the daemon left empty. // omitting whichever parts the daemon left empty.
func (e apiError) String() string { 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 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e apiError var e apiError
_ = json.NewDecoder(resp.Body).Decode(&e) _ = json.NewDecoder(resp.Body).Decode(&e)
if e.Message == "" { return apiResponseError{StatusCode: resp.StatusCode, ErrorBody: e}
return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode)
}
return fmt.Errorf("%s", e.String())
} }
if out != nil { if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil {

View File

@ -37,6 +37,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/ports"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile" "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" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" 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 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 // 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 // the CLI's request body. Every other method is a no-op so it satisfies the
// projectsvc.Manager interface. // projectsvc.Manager interface.
@ -119,8 +146,9 @@ func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error)
return nil, nil return nil, nil
} }
func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { func (f *fakeProjectManager) Get(_ context.Context, id domain.ProjectID) (projectsvc.GetResult, error) {
return projectsvc.GetResult{}, nil 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) { 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)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
Agents: &fakeAgentCatalog{},
Sessions: sessions, Sessions: sessions,
Projects: projects, Projects: projects,
}, httpd.ControlDeps{}) }, httpd.ControlDeps{})

View File

@ -184,6 +184,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
root.AddCommand(newStopCommand(ctx)) root.AddCommand(newStopCommand(ctx))
root.AddCommand(newStatusCommand(ctx)) root.AddCommand(newStatusCommand(ctx))
root.AddCommand(newDoctorCommand(ctx)) root.AddCommand(newDoctorCommand(ctx))
root.AddCommand(newAgentCommand(ctx))
root.AddCommand(newSpawnCommand(ctx)) root.AddCommand(newSpawnCommand(ctx))
root.AddCommand(newSendCommand(ctx)) root.AddCommand(newSendCommand(ctx))
root.AddCommand(newPreviewCommand(ctx)) root.AddCommand(newPreviewCommand(ctx))

View File

@ -2,9 +2,14 @@ package cli
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"os"
"path/filepath"
"runtime" "runtime"
"sort"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -27,6 +32,7 @@ type spawnOptions struct {
name string name string
claimPR string claimPR string
noTakeover bool noTakeover bool
skipAgentCheck bool
} }
// spawnRequest mirrors the daemon's SpawnSessionRequest body for // spawnRequest mirrors the daemon's SpawnSessionRequest body for
@ -37,7 +43,7 @@ type spawnRequest struct {
Harness string `json:"harness,omitempty"` Harness string `json:"harness,omitempty"`
Branch string `json:"branch,omitempty"` Branch string `json:"branch,omitempty"`
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName,omitempty"`
} }
type spawnResult struct { type spawnResult struct {
@ -47,6 +53,12 @@ type spawnResult struct {
} `json:"session"` } `json:"session"`
} }
type agentProbeResult struct {
Agent agentInfo `json:"agent"`
Supported bool `json:"supported"`
Installed bool `json:"installed"`
}
func newSpawnCommand(ctx *commandContext) *cobra.Command { func newSpawnCommand(ctx *commandContext) *cobra.Command {
var opts spawnOptions var opts spawnOptions
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -57,25 +69,33 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
"fresh git worktree. Register the project first with `ao project add`.", "fresh git worktree. Register the project first with `ao project add`.",
Args: noArgs, Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error { 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 == "" { if opts.noTakeover && opts.claimPR == "" {
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")} return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
} }
claimRef := "" if explicitName := strings.TrimSpace(opts.name); utf8.RuneCountInString(explicitName) > maxDisplayNameLen {
if opts.claimPR != "" { return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
project, err := ctx.fetchProjectDetails(cmd.Context(), opts.project) }
project, err := ctx.resolveSpawnProject(cmd.Context(), opts.project)
if err != nil { if err != nil {
return err 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) claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project)
if err != nil { if err != nil {
return err return err
@ -123,7 +143,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
} else { } else {
attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)" 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 return err
}, },
} }
@ -136,17 +156,274 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
} }
return pflag.NormalizedName(name) 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.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/<session-id>/root)") f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") 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.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.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.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 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 <repo-path> --worker-agent <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 <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 // rollbackSpawnedSession reverses a partial `spawn` whose out-of-band follow-up
// (PR claim) failed. It calls the daemon's `/rollback` endpoint, which deletes // (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 // the seed-state row outright instead of marking it terminated — so the user

View File

@ -6,23 +6,44 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
) )
// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing func authorizedAgentsJSON(agent string) string {
// --project before touching the network, so it fails fast without a daemon. info := `{"id":` + jsonQuote(agent) + `,"label":` + jsonQuote(agent) + `,"authStatus":"authorized"}`
func TestSpawnCommand_RequiresProject(t *testing.T) { return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}`
var out, errb bytes.Buffer }
root := NewRootCommand(Deps{Out: &out, Err: &errb})
root.SetArgs([]string{"spawn"}) // TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project
err := root.Execute() // setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one.
if err == nil { func TestSpawnCommand_MissingProjectContext(t *testing.T) {
t.Fatal("expected an error when --project is missing") 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
} }
if !strings.Contains(err.Error(), "--project is required") { http.NotFound(w, r)
t.Fatalf("error = %v, want it to mention --project is required", err) }))
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 context is missing")
}
if !strings.Contains(err.Error(), "ao project add --path <repo-path> --worker-agent <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 { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": 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"}}`) _, _ = 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": case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
_, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`) _, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-9/pr/claim": 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) t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv) 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 { if err != nil {
t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut) t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut)
} }
if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") { if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") {
t.Fatalf("output missing claimed label: %s", out) 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) { if !reflect.DeepEqual(requests, want) {
t.Fatalf("requests=%#v want %#v", requests, want) t.Fatalf("requests=%#v want %#v", requests, want)
} }
@ -89,6 +112,8 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": 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"}}`) _, _ = 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": case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
sessions["demo-10"] = true sessions["demo-10"] = true
_, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`) _, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`)
@ -108,7 +133,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv) 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 { if err == nil {
t.Fatal("expected spawn claim failure") t.Fatal("expected spawn claim failure")
} }
@ -119,7 +144,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
if sessions["demo-10"] { if sessions["demo-10"] {
t.Fatalf("spawned session still present after claim rollback: %#v", sessions) 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) { if !reflect.DeepEqual(requests, want) {
t.Fatalf("requests=%#v want %#v", 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 // TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name
// longer than 20 characters without contacting the daemon. // longer than 20 characters without contacting the daemon.
func TestSpawnCommand_RejectsOverlongName(t *testing.T) { 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)) 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)
}
}

View File

@ -132,10 +132,16 @@ func Run() error {
} }
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log) lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log)
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) 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{ 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}), Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}),
Agents: agentsvc.New(), Agents: agentSvc,
Sessions: sessionSvc, Sessions: sessionSvc,
Reviews: reviewSvc, Reviews: reviewSvc,
Notifications: notifier, Notifications: notifier,

View File

@ -33,6 +33,45 @@ paths:
summary: Return cached supported and locally installed agent adapters summary: Return cached supported and locally installed agent adapters
tags: tags:
- agents - 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: /api/v1/agents/refresh:
post: post:
operationId: refreshAgents operationId: refreshAgents
@ -1975,6 +2014,19 @@ components:
- targetSha - targetSha
- status - status
type: object type: object
ProbeAgentResponse:
properties:
agent:
$ref: '#/components/schemas/AgentInfo'
installed:
type: boolean
supported:
type: boolean
required:
- agent
- supported
- installed
type: object
Project: Project:
properties: properties:
agent: agent:

View File

@ -137,6 +137,7 @@ var schemaNames = map[string]string{
// httpd/controllers (wire envelopes) // httpd/controllers (wire envelopes)
"ControllersListProjectsResponse": "ListProjectsResponse", "ControllersListProjectsResponse": "ListProjectsResponse",
"ControllersProjectResponse": "ProjectResponse", "ControllersProjectResponse": "ProjectResponse",
"ControllersAgentIDParam": "AgentIDParam",
"ControllersGetProjectResponse": "ProjectGetResponse", "ControllersGetProjectResponse": "ProjectGetResponse",
"ControllersProjectOrDegraded": "ProjectOrDegraded", "ControllersProjectOrDegraded": "ProjectOrDegraded",
"ControllersListSessionsQuery": "ListSessionsQuery", "ControllersListSessionsQuery": "ListSessionsQuery",
@ -174,6 +175,7 @@ var schemaNames = map[string]string{
"ControllersOrchestratorResponse": "OrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse",
"AgentInventory": "ListAgentsResponse", "AgentInventory": "ListAgentsResponse",
"AgentInfo": "AgentInfo", "AgentInfo": "AgentInfo",
"AgentProbeResult": "ProbeAgentResponse",
"ControllersListNotificationsQuery": "ListNotificationsQuery", "ControllersListNotificationsQuery": "ListNotificationsQuery",
"ControllersNotificationStreamQuery": "NotificationStreamQuery", "ControllersNotificationStreamQuery": "NotificationStreamQuery",
"ControllersNotificationIDParam": "NotificationIDParam", "ControllersNotificationIDParam": "NotificationIDParam",
@ -313,6 +315,17 @@ func agentOperations() []operation {
{http.StatusNotImplemented, envelope.APIError{}}, {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{}},
},
},
} }
} }

View File

@ -3,6 +3,7 @@ package controllers
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -15,6 +16,7 @@ import (
type AgentCatalog interface { type AgentCatalog interface {
List(ctx context.Context) (agentsvc.Inventory, error) List(ctx context.Context) (agentsvc.Inventory, error)
Refresh(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. // AgentsController owns the /agents routes.
@ -26,6 +28,7 @@ type AgentsController struct {
func (c *AgentsController) Register(r chi.Router) { func (c *AgentsController) Register(r chi.Router) {
r.Get("/agents", c.list) r.Get("/agents", c.list)
r.Post("/agents/refresh", c.refresh) r.Post("/agents/refresh", c.refresh)
r.Post("/agents/{agent}/probe", c.probe)
} }
func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) { 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) 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)
}

View File

@ -17,9 +17,12 @@ import (
type fakeAgentCatalog struct { type fakeAgentCatalog struct {
inventory agentsvc.Inventory inventory agentsvc.Inventory
refreshed agentsvc.Inventory refreshed agentsvc.Inventory
probed agentsvc.ProbeResult
err error err error
listCalls int listCalls int
refreshCalls int refreshCalls int
probeCalls int
probeAgent string
} }
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { 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 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) { func TestListAgents(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{ 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) 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)
}
}

View File

@ -27,6 +27,11 @@ type ProjectIDParam struct {
ID string `path:"id" description:"Project identifier (registry key)."` 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. // ListProjectsResponse is the body of GET /api/v1/projects.
type ListProjectsResponse struct { type ListProjectsResponse struct {
Projects []projectsvc.Summary `json:"projects"` Projects []projectsvc.Summary `json:"projects"`
@ -442,6 +447,9 @@ type ListAgentsResponse = agentsvc.Inventory
// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh. // RefreshAgentsResponse is the body of POST /api/v1/agents/refresh.
type RefreshAgentsResponse = agentsvc.Inventory 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. // AgentInfo is one supported or installed agent entry.
type AgentInfo = agentsvc.Info type AgentInfo = agentsvc.Info

View File

@ -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 { func harnessAgent(id, label string, err error) agentregistry.HarnessAgent {
return agentregistry.HarnessAgent{ return agentregistry.HarnessAgent{
Harness: domain.AgentHarness(id), Harness: domain.AgentHarness(id),

View File

@ -23,6 +23,13 @@ type probeResult struct {
authorized bool 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. // Info is the user-facing identity for an agent adapter.
type Info struct { type Info struct {
ID string `json:"id"` ID string `json:"id"`
@ -140,6 +147,31 @@ func (s *Service) Refresh(ctx context.Context) (Inventory, error) {
return next, nil 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 { func supportedInfos(agents []agentregistry.HarnessAgent) []Info {
supported := make([]Info, 0, len(agents)) supported := make([]Info, 0, len(agents))
for _, item := range agents { for _, item := range agents {

View File

@ -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 surface and the `running.json` handshake. It must not open SQLite directly or
call runtime, workspace, tracker, or agent adapters in-process. 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 ## Current commands
Every product command resolves to a daemon HTTP route. Run `ao <command> Every product command resolves to a daemon HTTP route. Run `ao <command>
@ -31,6 +43,8 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
| `ao project get <id>` | `GET /api/v1/projects/{id}` | | `ao project get <id>` | `GET /api/v1/projects/{id}` |
| `ao project set-config <id>` | `PUT /api/v1/projects/{id}/config` | | `ao project set-config <id>` | `PUT /api/v1/projects/{id}/config` |
| `ao project rm <id>` | `DELETE /api/v1/projects/{id}` | | `ao project rm <id>` | `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 spawn` | `POST /api/v1/sessions` |
| `ao session ls` | `GET /api/v1/sessions` | | `ao session ls` | `GET /api/v1/sessions` |
| `ao session get <id>` | `GET /api/v1/sessions/{id}` | | `ao session get <id>` | `GET /api/v1/sessions/{id}` |
@ -44,6 +58,23 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
| `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` | | `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` |
| `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) | | `ao hooks <agent> <event>` | `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 `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 (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 autodetects an `index.html` in the session workspace; with a URL argument it

View File

@ -21,6 +21,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/agents/refresh": {
parameters: { parameters: {
query?: never; query?: never;
@ -708,6 +725,11 @@ export interface components {
targetSha: string; targetSha: string;
title: string; title: string;
}; };
ProbeAgentResponse: {
agent: components["schemas"]["AgentInfo"];
installed: boolean;
supported: boolean;
};
Project: { Project: {
agent?: string; agent?: string;
config?: components["schemas"]["ProjectConfig"]; 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: { refreshAgents: {
parameters: { parameters: {
query?: never; query?: never;