Merge branch 'main' into ao/reverbcode-2/issue-2272-prompts

This commit is contained in:
Adil Shaikh 2026-07-01 04:31:30 +05:30 committed by GitHub
commit 077042ae56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 672 additions and 137 deletions

View File

@ -236,7 +236,7 @@ func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows,
if err != nil {
return nil, err
}
return ptyexec.Spawn(ctx, argv, nil, rows, cols)
return ptyexec.Spawn(ctx, argv, attachEnv(os.Environ()), rows, cols)
}
// attachCommand returns the argv to attach a terminal to the session.
@ -249,6 +249,17 @@ func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, error) {
return []string{r.binary, "attach-session", "-t", id}, nil
}
func attachEnv(base []string) []string {
env := append([]string(nil), base...)
for i, kv := range env {
if strings.HasPrefix(kv, "TERM=") {
env[i] = "TERM=xterm-256color"
return env
}
}
return append(env, "TERM=xterm-256color")
}
// run wraps runner.Run with a per-call timeout context.
func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
cmdCtx, cancel := context.WithTimeout(ctx, r.timeout)

View File

@ -551,6 +551,18 @@ func TestAttachCommandRejectsInvalidHandle(t *testing.T) {
}
}
func TestAttachEnvForcesUsableTerm(t *testing.T) {
env := attachEnv([]string{"PATH=/bin", "TERM=dumb", "SHELL=/bin/sh"})
if got, want := env, []string{"PATH=/bin", "TERM=xterm-256color", "SHELL=/bin/sh"}; !reflect.DeepEqual(got, want) {
t.Fatalf("attachEnv = %#v, want %#v", got, want)
}
env = attachEnv([]string{"PATH=/bin"})
if got, want := env, []string{"PATH=/bin", "TERM=xterm-256color"}; !reflect.DeepEqual(got, want) {
t.Fatalf("attachEnv without TERM = %#v, want %#v", got, want)
}
}
// -- commandError tests --
func TestCommandErrorUnwraps(t *testing.T) {

View File

@ -307,7 +307,7 @@ func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck {
func (c *commandContext) checkTmux(ctx context.Context) doctorCheck {
path, err := c.deps.LookPath("tmux")
if err != nil || path == "" {
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"}
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH; required on macOS/Linux to start sessions"}
}
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()

View File

@ -247,12 +247,18 @@ func TestDoctorJSONOutputIsDecodable(t *testing.T) {
clearDoctorGitHubEnv(t)
out, errOut, err := executeCLI(t, Deps{
LookPath: func(name string) (string, error) {
if name == "git" {
switch name {
case "git":
return "/bin/git", nil
case "tmux":
return "/bin/tmux", nil
}
return "", errors.New("missing")
},
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) {
if name == "/bin/tmux" {
return []byte("tmux 3.3a\n"), nil
}
return []byte("git version 2.43.0\n"), nil
},
ProcessAlive: func(int) bool { return false },
@ -277,12 +283,18 @@ func TestDoctorTextOutputIsGrouped(t *testing.T) {
clearDoctorGitHubEnv(t)
out, errOut, err := executeCLI(t, Deps{
LookPath: func(name string) (string, error) {
if name == "git" {
switch name {
case "git":
return "/bin/git", nil
case "tmux":
return "/bin/tmux", nil
}
return "", errors.New("missing")
},
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) {
if name == "/bin/tmux" {
return []byte("tmux 3.3a\n"), nil
}
return []byte("git version 2.43.0\n"), nil
},
ProcessAlive: func(int) bool { return false },

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

@ -48,7 +48,7 @@ type ProjectConfig struct {
// Reviewers names the agent(s) that review a worker's PR when a review is
// triggered. It is configured independently of the Worker override; an empty
// list falls back to the worker's own harness (see ResolveReviewerHarness).
// list falls back to claude-code (see ResolveReviewerHarness).
Reviewers []ReviewerConfig `json:"reviewers,omitempty"`
}
@ -64,15 +64,11 @@ type ReviewerConfig struct {
const FallbackReviewerHarness = ReviewerClaudeCode
// ResolveReviewerHarness picks the reviewer harness for a worker. A configured
// reviewer wins; otherwise it reuses the worker's own harness when that harness
// is also a supported reviewer, falling back to claude-code.
func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) ReviewerHarness {
// reviewer wins; otherwise claude-code is used.
func (c ProjectConfig) ResolveReviewerHarness(_ AgentHarness) ReviewerHarness {
if len(c.Reviewers) > 0 {
return c.Reviewers[0].Harness
}
if h := ReviewerHarness(workerHarness); h.IsKnown() {
return h
}
return FallbackReviewerHarness
}

View File

@ -83,13 +83,12 @@ func TestResolveReviewerHarness(t *testing.T) {
t.Fatalf("configured reviewer = %q, want claude-code", got)
}
// No reviewer configured: reuse the worker harness when it is also a
// supported reviewer (claude-code is).
// No reviewer configured: always use claude-code.
if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessClaudeCode); got != ReviewerClaudeCode {
t.Fatalf("default = %q, want reviewer claude-code", got)
}
// A worker harness that is not a supported reviewer falls back to claude-code.
// A worker harness that is not claude-code also falls back to claude-code.
if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessAider); got != FallbackReviewerHarness {
t.Fatalf("fallback = %q, want %q", got, FallbackReviewerHarness)
}

View File

@ -2470,6 +2470,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

@ -164,6 +164,9 @@ var (
// conflict markers for manual resolution. Adapters wrap this sentinel via
// fmt.Errorf so callers can match it with errors.Is.
ErrPreservedConflict = errors.New("workspace: preserved apply produced conflicts")
// ErrRuntimePrerequisite reports a missing host prerequisite for the selected
// runtime before a session can be created.
ErrRuntimePrerequisite = errors.New("runtime: prerequisite missing")
)
// WorkspaceConfig is the spec for creating or restoring a session's workspace.

View File

@ -14,11 +14,11 @@ var ErrSessionNotFound = errors.New("session not found")
type SpawnConfig struct {
ProjectID domain.ProjectID
IssueID domain.IssueID
// IssueContext is optional pre-fetched tracker context for the task prompt.
// Standing rules stay in SystemPrompt; issue facts belong to the user task.
IssueContext string
Kind domain.SessionKind
Harness domain.AgentHarness
Branch string
Prompt string
Kind domain.SessionKind
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

@ -375,8 +375,7 @@ func (e *Engine) List(ctx stdctx.Context, workerID domain.SessionID) (SessionRev
}
// reviewerHarness resolves which harness reviews the worker's PR: a configured
// reviewer wins, otherwise the worker's own harness is reused (falling back to
// claude-code), per domain.ResolveReviewerHarness.
// reviewer wins, otherwise claude-code is used, per domain.ResolveReviewerHarness.
func (e *Engine) reviewerHarness(ctx stdctx.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) {
var cfg domain.ProjectConfig
if e.projects != nil {

View File

@ -481,6 +481,8 @@ func toAPIError(err error) error {
return apierr.Invalid("INVALID_BRANCH", err.Error(), nil)
case errors.Is(err, ports.ErrAgentBinaryNotFound):
return apierr.Invalid("AGENT_BINARY_NOT_FOUND", err.Error(), nil)
case errors.Is(err, ports.ErrRuntimePrerequisite):
return apierr.Invalid("RUNTIME_PREREQUISITE_MISSING", err.Error(), nil)
default:
return err
}

View File

@ -534,6 +534,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) {
{"not fetched", fmt.Errorf("spawn mer-1: workspace: %w: \"x\" has no local head", ports.ErrWorkspaceBranchNotFetched), apierr.KindInvalid, "BRANCH_NOT_FETCHED"},
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"},
{"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
{"runtime prerequisite missing", fmt.Errorf("spawn: %w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite), apierr.KindInvalid, "RUNTIME_PREREQUISITE_MISSING"},
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"},
{"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"},
}

View File

@ -96,6 +96,8 @@ type Store interface {
// presence of any row is the marker; preserved_ref may be empty for clean
// worktrees.
ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error)
// DeleteSessionWorktrees clears the "shutdown-saved" restore marker.
DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error
}
// Manager coordinates internal session spawn, restore, kill, and cleanup over
@ -202,6 +204,10 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
return domain.SessionRecord{}, fmt.Errorf("spawn: %w: %q", ErrUnknownHarness, cfg.Harness)
}
if err := m.validateRuntimePrerequisites(); err != nil {
return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err)
}
prompt, systemPrompt, err := m.buildSpawnTexts(ctx, cfg)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err)
@ -242,19 +248,19 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// post-create commands (e.g. `pnpm install`) before the agent launches.
if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err)
}
agent, ok := m.agents.Agent(cfg.Harness)
if !ok {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
}
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
}
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
@ -270,7 +276,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err)
}
// Pre-flight: confirm argv[0] actually exists on PATH (or as an absolute
@ -279,7 +285,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// unresolved binary would leak through as a "live" session that never ran.
if err := m.validateAgentBinary(argv); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
}
handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{
@ -290,7 +296,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
}
@ -450,6 +456,12 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
return false, fmt.Errorf("kill %s: %w", id, err)
}
// Clear the restore marker so the next boot's RestoreAll cannot resurrect a
// killed session (#2319). Best-effort: teardown below still matters.
if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil {
m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err)
}
// Only tear down what exists. A session may have lost its handle after a
// crash or never acquired one if spawn failed partway.
if handle.ID != "" {
@ -847,6 +859,12 @@ func (m *Manager) RestoreAll(ctx context.Context) error {
m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err)
}
}
// One-shot: drop the consumed marker so it never outlives one restart
// (#2319). A still-live session re-acquires it at the next quit.
if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil {
m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err)
}
}
return nil
}
@ -928,13 +946,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},
}
}
@ -1300,6 +1319,16 @@ func (m *Manager) validateAgentBinary(argv []string) error {
return nil
}
func (m *Manager) validateRuntimePrerequisites() error {
if runtime.GOOS == "windows" {
return nil
}
if path, err := m.lookPath("tmux"); err != nil || path == "" {
return fmt.Errorf("%w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite)
}
return nil
}
func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle {
return ports.RuntimeHandle{ID: meta.RuntimeHandleID}
}

View File

@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@ -112,6 +113,10 @@ func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionW
func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) {
return f.worktrees[id], nil
}
func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error {
delete(f.worktrees, id)
return nil
}
type fakeLCM struct {
store *fakeStore
@ -465,8 +470,8 @@ func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) {
if ws.destroyed != 1 {
t.Fatal("workspace should roll back")
}
if !st.sessions["mer-1"].IsTerminated {
t.Fatal("orphaned spawn should be terminated")
if rec, present := st.sessions["mer-1"]; present {
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
}
}
@ -569,6 +574,29 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
t.Fatalf("kill err = %v, want workspace error surfaced", err)
}
}
// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit
// terminal intent and must delete the session_worktrees "shutdown-saved" marker.
// A session that carried a marker (e.g. it survived a prior reopen cycle) and is
// then killed must not keep that marker, or the next boot's RestoreAll would
// resurrect it.
func TestKill_DeletesRestoreMarker(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = mkLive("mer-1")
// The session carries a leftover shutdown-saved marker from a prior cycle.
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
if _, err := m.Kill(ctx, "mer-1"); err != nil {
t.Fatalf("kill err = %v", err)
}
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("kill must delete the restore marker, got %d rows", len(rows))
}
}
func TestRestore_ReopensTerminal(t *testing.T) {
m, st, rt, _ := newManager()
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
@ -1248,6 +1276,9 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
notFound := func(name string) (string, error) {
if name == "tmux" {
return "/bin/tmux", nil
}
return "", fmt.Errorf("exec: %q: not found", name)
}
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: notFound})
@ -1262,8 +1293,39 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
if ws.destroyed != 1 {
t.Fatal("workspace must be torn down when the pre-launch binary check fails")
}
if !st.sessions["mer-1"].IsTerminated {
t.Fatal("the orphan row should be marked terminated after the failed spawn")
if rec, present := st.sessions["mer-1"]; present {
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
}
}
func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows uses ConPTY, not tmux")
}
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
lookPath := func(name string) (string, error) {
if name == "tmux" {
return "", fmt.Errorf("exec: %q: not found", name)
}
return "/bin/true", nil
}
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
if !errors.Is(err, ports.ErrRuntimePrerequisite) || !strings.Contains(err.Error(), "tmux required") {
t.Fatalf("err = %v, want missing tmux prerequisite", err)
}
if len(st.sessions) != 0 {
t.Fatalf("no session row should be created before runtime prerequisites pass, got %d", len(st.sessions))
}
if ws.lastCfg.SessionID != "" || ws.destroyed != 0 {
t.Fatal("workspace must not be created when tmux is missing")
}
if rt.created != 0 {
t.Fatal("runtime must not be created when tmux is missing")
}
}
@ -1693,6 +1755,81 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) {
}
}
// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the
// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its
// session_worktrees marker is deleted, so a second RestoreAll (with no fresh
// marker) does NOT relaunch it again.
func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) {
m, st, rt, _ := newLifecycleManager()
st.sessions["mer-1"] = domain.SessionRecord{
ID: "mer-1",
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessClaudeCode,
IsTerminated: true,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
Activity: domain.Activity{State: domain.ActivityExited},
}
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("RestoreAll err = %v", err)
}
if rt.created != 1 {
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
}
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows))
}
}
// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c),
// the killed-session-resurrection scenario. A terminated session WITH a marker
// is relaunched exactly once; on a second RestoreAll (no new marker) it stays
// terminated and is not relaunched again.
func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) {
m, st, rt, _ := newLifecycleManager()
st.sessions["mer-1"] = domain.SessionRecord{
ID: "mer-1",
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessClaudeCode,
IsTerminated: true,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
Activity: domain.Activity{State: domain.ActivityExited},
}
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
// First boot: marker present, session relaunches once.
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("first RestoreAll err = %v", err)
}
if rt.created != 1 {
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
}
// Simulate the user killing the relaunched session before the next quit, so
// it has no fresh marker, then a second boot.
if _, err := m.Kill(ctx, "mer-1"); err != nil {
t.Fatalf("kill err = %v", err)
}
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("second RestoreAll err = %v", err)
}
if rt.created != 1 {
t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created)
}
if !st.sessions["mer-1"].IsTerminated {
t.Error("killed session must remain terminated after second RestoreAll")
}
}
// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
// restore but before relaunching.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 224 KiB

View File

@ -883,6 +883,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;

View File

@ -5,6 +5,7 @@ import {
dialog,
ipcMain,
net,
nativeImage,
Notification as ElectronNotification,
protocol,
shell,
@ -156,6 +157,16 @@ function windowIconPath(): string | undefined {
return existsSync(candidate) ? candidate : undefined;
}
function applyRuntimeAppIcon(): void {
if (process.platform !== "darwin") return;
const iconPath = windowIconPath();
if (!iconPath) return;
const icon = nativeImage.createFromPath(iconPath);
if (!icon.isEmpty()) {
app.dock.setIcon(icon);
}
}
function setDaemonStatus(nextStatus: DaemonStatus): void {
daemonStatus = nextStatus;
mainWindow?.webContents.send("daemon:status", daemonStatus);
@ -952,6 +963,7 @@ app.whenReady().then(async () => {
}
registerRendererProtocol();
applyRuntimeAppIcon();
createWindow();
void startDaemon();
initAutoUpdates();

View File

@ -1,15 +1,18 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NewTaskDialog } from "./NewTaskDialog";
const { postMock } = vi.hoisted(() => ({
const { getMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(),
postMock: vi.fn(),
}));
vi.mock("../lib/api-client", () => ({
apiClient: {
POST: postMock,
GET: (...args: unknown[]) => getMock(...args),
POST: (...args: unknown[]) => postMock(...args),
},
apiErrorMessage: (error: unknown, fallback = "Request failed") => {
if (typeof error === "object" && error !== null && "message" in error) {
@ -19,27 +22,48 @@ vi.mock("../lib/api-client", () => ({
},
}));
function renderDialog() {
const onCreated = vi.fn();
const onOpenChange = vi.fn();
render(
<QueryClientProvider client={new QueryClient()}>
<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />
</QueryClientProvider>,
);
return { onCreated, onOpenChange };
}
function spawnBody() {
return (postMock.mock.calls[0][1] as { body: Record<string, unknown> }).body;
}
beforeEach(() => {
postMock.mockReset();
postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined });
getMock.mockReset().mockResolvedValue({
data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } },
error: undefined,
});
postMock.mockReset().mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined });
});
describe("NewTaskDialog", () => {
it("starts a worker task with the entered title and brief", async () => {
const onCreated = vi.fn();
const onOpenChange = vi.fn();
render(<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />);
afterEach(() => vi.restoreAllMocks());
await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer");
await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails.");
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
describe("NewTaskDialog", () => {
it("preselects the project's default agent and omits harness so the daemon applies it", async () => {
const { onCreated, onOpenChange } = renderDialog();
const user = userEvent.setup();
await screen.findByText("claude-code");
await user.type(screen.getByLabelText("Title"), "Fix fallback renderer");
await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails.");
await user.click(screen.getByRole("button", { name: "Start task" }));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: {
projectId: "proj-1",
kind: "worker",
harness: "codex",
harness: undefined,
issueId: "Fix fallback renderer",
prompt: "Restore the fallback renderer after WebGL init fails.",
branch: undefined,
@ -49,10 +73,28 @@ describe("NewTaskDialog", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("requires both title and brief", async () => {
render(<NewTaskDialog open projectId="proj-1" onCreated={vi.fn()} onOpenChange={vi.fn()} />);
it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => {
renderDialog();
const user = userEvent.setup();
await screen.findByText("claude-code");
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
await user.type(screen.getByLabelText("Title"), "T");
await user.type(screen.getByLabelText("Brief"), "B");
await user.click(screen.getByRole("combobox", { name: "Agent" }));
await user.click(await screen.findByRole("option", { name: "cursor" }));
await user.click(screen.getByRole("button", { name: "Start task" }));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(spawnBody().harness).toBe("cursor");
});
it("requires both title and brief", async () => {
renderDialog();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Start task" }));
expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument();
expect(postMock).not.toHaveBeenCalled();

View File

@ -1,12 +1,16 @@
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery } from "@tanstack/react-query";
import { Loader2, X } from "lucide-react";
import { type FormEvent, useEffect, useId, useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { RequiredAgentField } from "./CreateProjectAgentSheet";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import type { AgentProvider } from "../types/workspace";
type Project = components["schemas"]["Project"];
type NewTaskDialogProps = {
open: boolean;
projectId?: string;
@ -14,35 +18,51 @@ type NewTaskDialogProps = {
onOpenChange: (open: boolean) => void;
};
const AGENTS: Array<{ value: AgentProvider; label: string }> = [
{ value: "codex", label: "Codex" },
{ value: "claude-code", label: "Claude Code" },
{ value: "opencode", label: "OpenCode" },
{ value: "aider", label: "Aider" },
];
export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) {
const titleId = useId();
const promptId = useId();
const branchId = useId();
const agentId = useId();
const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState("");
const [branch, setBranch] = useState("");
const [agent, setAgent] = useState<AgentProvider>("codex");
const [agent, setAgent] = useState("");
const [agentTouched, setAgentTouched] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | undefined>();
const projectQuery = useQuery({
queryKey: ["project", projectId],
enabled: open && Boolean(projectId),
queryFn: async () => {
const { data, error: apiError } = await apiClient.GET("/api/v1/projects/{id}", {
params: { path: { id: projectId as string } },
});
if (apiError) throw new Error(apiErrorMessage(apiError));
if (data?.status !== "ok") throw new Error("Project config is unavailable.");
return data.project as Project;
},
});
const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? "";
useEffect(() => {
if (!open) {
setTitle("");
setPrompt("");
setBranch("");
setAgent("codex");
setAgent("");
setAgentTouched(false);
setError(undefined);
setIsSubmitting(false);
}
}, [open]);
useEffect(() => {
if (open && !agentTouched) {
setAgent(defaultWorkerAgent);
}
}, [open, agentTouched, defaultWorkerAgent]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!projectId || isSubmitting) return;
@ -62,7 +82,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
body: {
projectId,
kind: "worker",
harness: agent,
harness: agentTouched && agent ? (agent as AgentProvider) : undefined,
issueId: cleanTitle,
prompt: cleanPrompt,
branch: cleanBranch || undefined,
@ -130,21 +150,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr]">
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-muted-foreground">Agent</label>
<Select value={agent} onValueChange={(value) => setAgent(value as AgentProvider)}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENTS.map((entry) => (
<SelectItem key={entry.value} value={entry.value}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<RequiredAgentField
id={agentId}
label="Agent"
placeholder="Project default"
value={agent}
onChange={(value) => {
setAgent(value);
setAgentTouched(true);
}}
/>
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-muted-foreground" htmlFor={branchId}>
Branch

View File

@ -270,7 +270,7 @@ function ReviewerSelect({ id, value, onChange }: { id: string; value: string; on
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Project default</SelectItem>
<SelectItem value="__default__">claude-code (default)</SelectItem>
{REVIEWER_OPTIONS.map((reviewer) => (
<SelectItem key={reviewer} value={reviewer}>
{reviewer}

View File

@ -49,6 +49,11 @@ const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
prs,
});
const sessionWithProvider = (prs: PullRequestFacts[], provider: WorkspaceSession["provider"]): WorkspaceSession => ({
...session(prs),
provider,
});
function renderWithQuery(children: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
@ -199,6 +204,36 @@ describe("SessionInspector reviews tab", () => {
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
});
it("shows claude-code as the default reviewer before a run exists", async () => {
getMock.mockImplementation(async (path: string) => {
if (path === "/api/v1/sessions/{sessionId}/reviews") {
return { data: { reviewerHandleId: "", reviews: [] } };
}
if (path === "/api/v1/projects/{id}") {
return {
data: {
status: "ok",
project: {
id: "ws-1",
kind: "git",
name: "my-app",
path: "/repo",
repo: "my-app",
defaultBranch: "main",
config: {},
},
},
};
}
return { data: undefined };
});
renderWithQuery(<SessionInspector session={sessionWithProvider([pr(3, "open")], "codex")} />);
await openReviewsTab();
expect(await screen.findByText("claude-code")).toBeInTheDocument();
});
it("shows eligible and up-to-date PR review rows", async () => {
mockCommonGets([approvedReview], "reviewer-pane", [
reviewState(3, "needs_review", "abc123"),

View File

@ -483,7 +483,7 @@ function ReviewPanel({
}
const latest = reviewStates.find((review) => review.latestRun)?.latestRun;
const harness = latest?.harness || config?.reviewers?.[0]?.harness || session.provider || "reviewer";
const harness = latest?.harness || config?.reviewers?.[0]?.harness || "claude-code";
const terminalEnabled = Boolean(reviewerHandleId && onOpenTerminal);
const aggregateVerdict = sessionReviewVerdict(reviewStates);
const runAction = reviewSessionRunAction(reviewStates, isTriggering);

View File

@ -4,13 +4,16 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Sidebar } from "./Sidebar";
import type { WorkspaceSummary } from "../types/workspace";
import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";
const { navigateMock, mockParams } = vi.hoisted(() => ({
const { navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
mockParams: { projectId: undefined as string | undefined },
renameSessionMock: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/rename-session", () => ({ renameSession: renameSessionMock }));
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
@ -29,15 +32,30 @@ const workspace: WorkspaceSummary = {
sessions: [],
};
const session: WorkspaceSession = {
id: "proj-1-1",
workspaceId: "proj-1",
workspaceName: "Project One",
title: "fix login",
provider: "claude-code",
kind: "worker",
branch: "session/proj-1-1",
status: "working",
updatedAt: "2026-06-30T00:00:00Z",
prs: [],
};
type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
type RemoveProjectHandler = (projectId: string) => Promise<void>;
function renderSidebar({
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
workspaces = [workspace],
}: {
onCreateProject?: CreateProjectHandler;
onRemoveProject?: RemoveProjectHandler;
workspaces?: WorkspaceSummary[];
} = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
@ -49,7 +67,7 @@ function renderSidebar({
daemonStatus={{ state: "running" }}
onCreateProject={onCreateProject}
onRemoveProject={onRemoveProject}
workspaces={[workspace]}
workspaces={workspaces}
/>
</SidebarProvider>
</QueryClientProvider>,
@ -64,6 +82,7 @@ async function chooseOption(trigger: HTMLElement, optionName: string) {
beforeEach(() => {
navigateMock.mockReset();
renameSessionMock.mockReset().mockResolvedValue(undefined);
mockParams.projectId = undefined;
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window, "alert").mockImplementation(() => undefined);
@ -161,6 +180,42 @@ describe("Sidebar", () => {
expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" });
});
it("renames a session inline and persists via the daemon", async () => {
const user = userEvent.setup();
const workspaceWithSession = { ...workspace, sessions: [session] };
renderSidebar({ workspaces: [workspaceWithSession] });
await user.click(screen.getByLabelText("Rename fix login"));
const input = screen.getByLabelText("Rename fix login");
await user.clear(input);
await user.type(input, "polish login{Enter}");
await waitFor(() => expect(renameSessionMock).toHaveBeenCalledWith("proj-1-1", "polish login"));
});
it("caps the inline rename input at 20 characters", async () => {
const user = userEvent.setup();
const workspaceWithSession = { ...workspace, sessions: [session] };
renderSidebar({ workspaces: [workspaceWithSession] });
await user.click(screen.getByLabelText("Rename fix login"));
expect(screen.getByLabelText("Rename fix login")).toHaveAttribute("maxlength", "20");
});
it("cancels the inline rename on Escape without calling the daemon", async () => {
const user = userEvent.setup();
const workspaceWithSession = { ...workspace, sessions: [session] };
renderSidebar({ workspaces: [workspaceWithSession] });
await user.click(screen.getByLabelText("Rename fix login"));
const input = screen.getByLabelText("Rename fix login");
await user.clear(input);
await user.type(input, "discard me{Escape}");
expect(renameSessionMock).not.toHaveBeenCalled();
expect(screen.getByLabelText("Open fix login")).toBeInTheDocument();
});
it("always shows action icons and reserves padding for them", () => {
renderSidebar();

View File

@ -6,13 +6,14 @@ import {
LayoutDashboard,
Moon,
MoreVertical,
Pencil,
Plus,
Search,
Settings,
Sun,
Trash2,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import {
attentionZone,
isOrchestratorSession,
@ -24,6 +25,7 @@ import {
import { aoBridge } from "../lib/bridge";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { renameSession } from "../lib/rename-session";
import { useEventsConnection } from "../hooks/useEventsConnection";
import { useResizable } from "../hooks/useResizable";
import {
@ -70,6 +72,10 @@ const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperti
const HOVER_ACTION_CLASS =
"grid size-5 shrink-0 place-items-center rounded-md text-passive transition-colors hover:bg-interactive-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-interactive-hover data-[state=open]:text-foreground [&_svg]:size-[15px]";
// Mirrors the daemon's display-name cap (maxDisplayNameLen) and the spawn
// `--name` flag, so inline edits never round-trip a value the API would reject.
const MAX_DISPLAY_NAME_LEN = 20;
type SidebarProps = {
daemonStatus: { state: string; message?: string };
underTopbar?: boolean;
@ -562,40 +568,122 @@ function ProjectItem({
sessions read as children without adding a persistent guide rail. */}
{expanded && sessions.length > 0 && (
<SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l-0 px-0 py-1 pl-2.5">
{sessions.map((session) => {
const active = selection.activeSessionId === session.id;
return (
<SidebarMenuSubItem key={session.id}>
<button
aria-current={active ? "page" : undefined}
aria-label={`Open ${session.title}`}
className={cn(
"relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5 text-left outline-hidden transition-[color]",
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
active && "text-foreground before:bg-accent",
)}
onClick={() => selection.goSession(workspace.id, session.id)}
type="button"
>
<SessionDot session={session} />
<span className="min-w-0 flex-1">
<span
className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}
>
{session.title}
</span>
</span>
</button>
</SidebarMenuSubItem>
);
})}
{sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
active={selection.activeSessionId === session.id}
onOpen={() => selection.goSession(workspace.id, session.id)}
/>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
}
// One worker-session row. Reads as a link by default; a hover-revealed pencil
// flips the label into an inline input (Enter/blur saves, Escape cancels) that
// persists through the daemon rename endpoint, so the new name survives reload.
function SessionRow({ session, active, onOpen }: { session: WorkspaceSession; active: boolean; onOpen: () => void }) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(session.title);
// Escape must not be swallowed by the blur-to-save path: the keydown handler
// blurs the input, so it flags a cancel here for onBlur to honour.
const cancelledRef = useRef(false);
const startEditing = () => {
setDraft(session.title);
setIsEditing(true);
};
const commit = async () => {
if (cancelledRef.current) {
cancelledRef.current = false;
setIsEditing(false);
return;
}
setIsEditing(false);
const name = draft.trim();
if (!name || name === session.title) return;
try {
await renameSession(session.id, name);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
} catch (err) {
console.error("Failed to rename session:", err);
}
};
if (isEditing) {
return (
<SidebarMenuSubItem>
<div className="relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5">
<SessionDot session={session} />
<input
aria-label={`Rename ${session.title}`}
autoFocus
className="min-w-0 flex-1 rounded-[3px] border border-accent bg-transparent px-1 py-px text-[12px] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-accent"
maxLength={MAX_DISPLAY_NAME_LEN}
onBlur={() => void commit()}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
e.preventDefault();
cancelledRef.current = true;
e.currentTarget.blur();
}
}}
value={draft}
/>
</div>
</SidebarMenuSubItem>
);
}
return (
<SidebarMenuSubItem>
<button
aria-current={active ? "page" : undefined}
aria-label={`Open ${session.title}`}
className={cn(
"relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-7 text-left outline-hidden transition-[color]",
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
active && "text-foreground before:bg-accent",
)}
onClick={onOpen}
type="button"
>
<SessionDot session={session} />
<span className="min-w-0 flex-1">
<span className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}>
{session.title}
</span>
</span>
</button>
{/* Pencil reveals on row hover/focus (named group on SidebarMenuSubItem);
it sits beside the row button rather than nested inside it. */}
<button
aria-label={`Rename ${session.title}`}
className={cn(
HOVER_ACTION_CLASS,
"absolute top-1/2 right-1 -translate-y-1/2 opacity-0",
"group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100",
)}
onClick={startEditing}
type="button"
>
<Pencil aria-hidden="true" />
</button>
</SidebarMenuSubItem>
);
}
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>

View File

@ -0,0 +1,14 @@
import { apiClient, apiErrorMessage } from "./api-client";
/** Update a session's display name via the daemon (PATCH /sessions/{id}). The
* daemon enforces the same 20-character limit as the spawn `--name` flag. */
export async function renameSession(sessionId: string, displayName: string): Promise<void> {
const { error, response } = await apiClient.PATCH("/api/v1/sessions/{sessionId}", {
params: { path: { sessionId } },
body: { displayName },
});
if (error) {
throw new Error(apiErrorMessage(error, `Failed to rename session (${response.status})`));
}
}