feat(spawn): add required --name flag for sidebar display name (#2302)

* feat(spawn): add required --name flag for sidebar display name

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(spawn): require --name for sidebar label; cap at 20 chars

Add a required --name flag to `ao spawn` that sets the session's
sidebar display name. The CLI rejects a missing or >20-character name
before contacting the daemon.

The daemon's POST /sessions keeps displayName optional (the desktop
new-task dialog omits it and the read model falls back to the session
id) but enforces the same 20-character cap when present, so a direct
API call cannot exceed it. The value flows CLI -> SpawnSessionRequest
-> SpawnConfig -> session record, and the existing read-model fallback
(displayName ?? issueId ?? id) renders it in the sidebar unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-30 16:26:48 +05:30 committed by GitHub
parent 194bb28c8c
commit 7c4a77d7cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 102 additions and 25 deletions

View File

@ -184,6 +184,7 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
"--branch", "feat/x", "--branch", "feat/x",
"--prompt", "hi", "--prompt", "hi",
"--issue", "ISS-1", "--issue", "ISS-1",
"--name", "my worker",
}) })
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("spawn execute: %v\noutput: %s", err, out.String()) 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" { if got.IssueID != "ISS-1" {
t.Errorf("IssueID = %q, want %q", 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")) { if !bytes.Contains(out.Bytes(), []byte("spawned session")) {
t.Errorf("output missing %q; got: %s", "spawned session", out.String()) t.Errorf("output missing %q; got: %s", "spawned session", out.String())
} }

View File

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"runtime" "runtime"
"strings"
"unicode/utf8"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
@ -12,12 +14,17 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "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 { type spawnOptions struct {
project string project string
harness string harness string
branch string branch string
prompt string prompt string
issue string issue string
name string
claimPR string claimPR string
noTakeover bool noTakeover bool
} }
@ -30,6 +37,7 @@ type spawnRequest struct {
Harness string `json:"harness,omitempty"` Harness string `json:"harness,omitempty"`
Branch string `json:"branch,omitempty"` Branch string `json:"branch,omitempty"`
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
DisplayName string `json:"displayName"`
} }
type spawnResult struct { type spawnResult struct {
@ -52,6 +60,13 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
if opts.project == "" { if opts.project == "" {
return usageError{fmt.Errorf("--project is required")} return usageError{fmt.Errorf("--project is required")}
} }
name := strings.TrimSpace(opts.name)
if name == "" {
return usageError{fmt.Errorf("--name is required")}
}
if utf8.RuneCountInString(name) > maxDisplayNameLen {
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
}
if opts.noTakeover && opts.claimPR == "" { if opts.noTakeover && opts.claimPR == "" {
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")} return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
} }
@ -72,6 +87,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
Harness: opts.harness, Harness: opts.harness,
Branch: opts.branch, Branch: opts.branch,
Prompt: opts.prompt, Prompt: opts.prompt,
DisplayName: name,
} }
var res spawnResult var res spawnResult
if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil { 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/<session-id>/root)") f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)")
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session") f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)") f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
return cmd return cmd

View File

@ -66,7 +66,7 @@ func TestSpawnClaimPRWiring(t *testing.T) {
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv) writeRunFileFor(t, cfg, srv)
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--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 { if err != nil {
t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut) t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut)
} }
@ -108,7 +108,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv) writeRunFileFor(t, cfg, srv)
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--claim-pr", "142") _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142")
if err == nil { if err == nil {
t.Fatal("expected spawn claim failure") t.Fatal("expected spawn claim failure")
} }
@ -126,8 +126,26 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
} }
func TestSpawnNoTakeoverRequiresClaimPR(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") { if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--no-takeover requires --claim-pr") {
t.Fatalf("err=%v exit=%d", err, ExitCode(err)) 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))
}
}

View File

@ -2464,6 +2464,9 @@ components:
properties: properties:
branch: branch:
type: string type: string
displayName:
maxLength: 20
type: string
harness: harness:
enum: enum:
- claude-code - claude-code

View File

@ -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"` 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"` Branch string `json:"branch,omitempty"`
Prompt string `json:"prompt,omitempty" maxLength:"4096"` 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. // SessionResponse is the { session } body shared by session create/get.

View File

@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -24,6 +25,7 @@ import (
const ( const (
maxPromptLen = 4096 maxPromptLen = 4096
maxMessageLen = 4096 maxMessageLen = 4096
maxDisplayNameLen = 20
) )
var errPreviewFileNotFound = errors.New("preview file not found") 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) envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil)
return 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 == "" { if in.Kind == "" {
in.Kind = domain.KindWorker 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 { if err != nil {
envelope.WriteError(w, r, err) envelope.WriteError(w, r, err)
return return

View File

@ -61,7 +61,7 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do
return domain.Session{}, f.spawnErr return domain.Session{}, f.spawnErr
} }
now := time.Now().UTC() 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 f.sessions[s.ID] = s
return s, nil return s, nil
} }
@ -269,7 +269,7 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
t.Fatalf("list leaked prompt: %s", body) 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 { if status != http.StatusCreated {
t.Fatalf("POST session = %d, want 201; body=%s", status, body) 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" { if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" {
t.Fatalf("spawned = %#v", spawned) 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", "") body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "")
if status != http.StatusOK { if status != http.StatusOK {
@ -679,6 +682,18 @@ func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) {
assertErrorCode(t, body, status, http.StatusBadRequest, "BRANCH_NOT_FETCHED") 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) { func TestSessionsAPI_RenameNotFound(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService()) srv := newSessionTestServer(t, newFakeSessionService())

View File

@ -18,4 +18,7 @@ type SpawnConfig struct {
Harness domain.AgentHarness Harness domain.AgentHarness
Branch string Branch string
Prompt 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
} }

View File

@ -928,6 +928,7 @@ func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord {
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
Harness: cfg.Harness, Harness: cfg.Harness,
DisplayName: cfg.DisplayName,
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now},
} }
} }

View File

@ -880,6 +880,7 @@ export interface components {
}; };
SpawnSessionRequest: { SpawnSessionRequest: {
branch?: string; branch?: string;
displayName?: string;
/** @enum {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"; 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; issueId?: string;