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:
parent
194bb28c8c
commit
7c4a77d7cc
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2464,6 +2464,9 @@ components:
|
|||
properties:
|
||||
branch:
|
||||
type: string
|
||||
displayName:
|
||||
maxLength: 20
|
||||
type: string
|
||||
harness:
|
||||
enum:
|
||||
- claude-code
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue