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:
parent
2c08597ee6
commit
821cda64a8
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "<message> (<code>) [request <id>]",
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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/<session-id>/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 <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
|
||||
// (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
|
||||
|
|
|
|||
|
|
@ -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 <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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <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 set-config <id>` | `PUT /api/v1/projects/{id}/config` |
|
||||
| `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 session ls` | `GET /api/v1/sessions` |
|
||||
| `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 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
|
||||
(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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue