diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 5c7e924ac..743a398d7 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -184,6 +184,7 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { "--branch", "feat/x", "--prompt", "hi", "--issue", "ISS-1", + "--name", "my worker", }) if err := root.Execute(); err != nil { t.Fatalf("spawn execute: %v\noutput: %s", err, out.String()) @@ -205,6 +206,9 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { if got.IssueID != "ISS-1" { t.Errorf("IssueID = %q, want %q", got.IssueID, "ISS-1") } + if got.DisplayName != "my worker" { + t.Errorf("DisplayName = %q, want %q (CLI json:\"displayName\" vs SpawnSessionRequest)", got.DisplayName, "my worker") + } if !bytes.Contains(out.Bytes(), []byte("spawned session")) { t.Errorf("output missing %q; got: %s", "spawned session", out.String()) } diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index d19d540d0..920b2b363 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -5,6 +5,8 @@ import ( "fmt" "net/url" "runtime" + "strings" + "unicode/utf8" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -12,12 +14,17 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" ) +// maxDisplayNameLen caps the sidebar label set by `--name`. Mirrored by the +// daemon's spawn handler so a direct API call is held to the same limit. +const maxDisplayNameLen = 20 + type spawnOptions struct { project string harness string branch string prompt string issue string + name string claimPR string noTakeover bool } @@ -25,11 +32,12 @@ type spawnOptions struct { // spawnRequest mirrors the daemon's SpawnSessionRequest body for // POST /api/v1/sessions. The CLI keeps its own copy so it need not import httpd. type spawnRequest struct { - ProjectID string `json:"projectId"` - IssueID string `json:"issueId,omitempty"` - Harness string `json:"harness,omitempty"` - Branch string `json:"branch,omitempty"` - Prompt string `json:"prompt,omitempty"` + ProjectID string `json:"projectId"` + IssueID string `json:"issueId,omitempty"` + Harness string `json:"harness,omitempty"` + Branch string `json:"branch,omitempty"` + Prompt string `json:"prompt,omitempty"` + DisplayName string `json:"displayName"` } type spawnResult struct { @@ -52,6 +60,13 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { 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")} } @@ -67,11 +82,12 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } } req := spawnRequest{ - ProjectID: opts.project, - IssueID: opts.issue, - Harness: opts.harness, - Branch: opts.branch, - Prompt: opts.prompt, + ProjectID: opts.project, + IssueID: opts.issue, + Harness: opts.harness, + Branch: opts.branch, + Prompt: opts.prompt, + DisplayName: name, } var res spawnResult if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil { @@ -125,6 +141,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao//root)") f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") + f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)") f.StringVar(&opts.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)") return cmd diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index 2abfcfaa4..68bf78b44 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -66,7 +66,7 @@ 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", "--claim-pr", "142", "--no-takeover") + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover") if err != nil { t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut) } @@ -108,7 +108,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", "--claim-pr", "142") + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142") if err == nil { t.Fatal("expected spawn claim failure") } @@ -126,8 +126,26 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { } func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) { - _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--no-takeover") + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", "worker", "--no-takeover") if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--no-takeover requires --claim-pr") { t.Fatalf("err=%v exit=%d", err, ExitCode(err)) } } + +// 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) { + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", strings.Repeat("x", 21)) + if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "20 characters or fewer") { + t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err)) + } +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 738a9dcef..c194ffa0b 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -2464,6 +2464,9 @@ components: properties: branch: type: string + displayName: + maxLength: 20 + type: string harness: enum: - claude-code diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index fca0790d2..ab859ef7a 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -146,6 +146,10 @@ type SpawnSessionRequest struct { Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode,grok,droid,amp,agy,crush,cursor,qwen,copilot,goose,auggie,continue,devin,cline,kimi,kiro,kilocode,vibe,pi,autohand"` Branch string `json:"branch,omitempty"` Prompt string `json:"prompt,omitempty" maxLength:"4096"` + // DisplayName is the sidebar label for the session, capped at 20 characters. + // `ao spawn --name` always sets it; other clients (e.g. the desktop new-task + // dialog) may omit it and fall back to the session id in the read model. + DisplayName string `json:"displayName,omitempty" maxLength:"20"` } // SessionResponse is the { session } body shared by session create/get. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index d10e72d4b..348f65ff1 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "unicode/utf8" "github.com/go-chi/chi/v5" @@ -22,8 +23,9 @@ import ( ) const ( - maxPromptLen = 4096 - maxMessageLen = 4096 + maxPromptLen = 4096 + maxMessageLen = 4096 + maxDisplayNameLen = 20 ) var errPreviewFileNotFound = errors.New("preview file not found") @@ -120,10 +122,19 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil) return } + // displayName is optional at the API (the desktop new-task dialog omits it + // and the read model falls back to the session id). `ao spawn` makes it + // required CLI-side. When present, it is held to the same length cap here so + // a direct API call cannot exceed it. + displayName := strings.TrimSpace(in.DisplayName) + if utf8.RuneCountInString(displayName) > maxDisplayNameLen { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "DISPLAY_NAME_TOO_LONG", "displayName must be 20 characters or fewer", nil) + return + } if in.Kind == "" { in.Kind = domain.KindWorker } - sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt}) + sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName}) if err != nil { envelope.WriteError(w, r, err) return diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index ab5455245..ae0fe53da 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -61,7 +61,7 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do return domain.Session{}, f.spawnErr } now := time.Now().UTC() - s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} + s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, DisplayName: cfg.DisplayName, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} f.sessions[s.ID] = s return s, nil } @@ -269,7 +269,7 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { t.Fatalf("list leaked prompt: %s", body) } - body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`) + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix","displayName":"my worker"}`) if status != http.StatusCreated { t.Fatalf("POST session = %d, want 201; body=%s", status, body) } @@ -280,6 +280,9 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" { t.Fatalf("spawned = %#v", spawned) } + if spawned.Session.DisplayName != "my worker" { + t.Fatalf("spawned displayName = %q, want %q", spawned.Session.DisplayName, "my worker") + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "") if status != http.StatusOK { @@ -679,6 +682,18 @@ func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) { assertErrorCode(t, body, status, http.StatusBadRequest, "BRANCH_NOT_FETCHED") } +// TestSessionsAPI_SpawnRejectsOverlongDisplayName asserts the spawn endpoint +// caps displayName at 20 characters even though the field itself is optional +// (the desktop new-task dialog omits it). `ao spawn` enforces the same limit +// CLI-side before the request is sent. +func TestSessionsAPI_SpawnRejectsOverlongDisplayName(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + overlong := strings.Repeat("x", 21) + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","harness":"codex","displayName":"`+overlong+`"}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_TOO_LONG") +} + func TestSessionsAPI_RenameNotFound(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) diff --git a/backend/internal/ports/session.go b/backend/internal/ports/session.go index 0c28f1792..035ad249e 100644 --- a/backend/internal/ports/session.go +++ b/backend/internal/ports/session.go @@ -18,4 +18,7 @@ type SpawnConfig struct { Harness domain.AgentHarness Branch string Prompt string + // DisplayName is the user-facing sidebar label. Empty falls back to the + // session id in the read model (e.g. orchestrator sessions). + DisplayName string } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index a4efb7eb3..9eee7a573 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -922,13 +922,14 @@ func (m *Manager) cleanupRecords(ctx context.Context, project domain.ProjectID) func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { return domain.SessionRecord{ - ProjectID: cfg.ProjectID, - IssueID: cfg.IssueID, - Kind: cfg.Kind, - CreatedAt: now, - UpdatedAt: now, - Harness: cfg.Harness, - Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + ProjectID: cfg.ProjectID, + IssueID: cfg.IssueID, + Kind: cfg.Kind, + CreatedAt: now, + UpdatedAt: now, + Harness: cfg.Harness, + DisplayName: cfg.DisplayName, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, } } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8a3b89818..9ee643dad 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -880,6 +880,7 @@ export interface components { }; SpawnSessionRequest: { branch?: string; + displayName?: string; /** @enum {string} */ harness?: "claude-code" | "codex" | "aider" | "opencode" | "grok" | "droid" | "amp" | "agy" | "crush" | "cursor" | "qwen" | "copilot" | "goose" | "auggie" | "continue" | "devin" | "cline" | "kimi" | "kiro" | "kilocode" | "vibe" | "pi" | "autohand"; issueId?: string;