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",
"--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())
}

View File

@ -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/<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.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

View File

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

View File

@ -2464,6 +2464,9 @@ components:
properties:
branch:
type: string
displayName:
maxLength: 20
type: string
harness:
enum:
- 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"`
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.

View File

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

View File

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

View File

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

View File

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

View File

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