diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 405bbac66..6e0ca0570 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -669,6 +669,58 @@ describe("interrupt_agent unblocks wait_agents", () => { expect(again.timed_out).toBe(false); expect(again.results).toEqual([]); }); + + test("late salvage attaches after wait collected an early interrupt", async () => { + const settle = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => {}, + interrupt: () => {}, + followup: async () => "", + }); + return settle.promise; + }); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const interrupt = createInterruptAgentTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + + const spawned = await callTool(spawn, { + description: "looping", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + + // Let onAgentReady register interrupt before we call interrupt_agent. + await new Promise((resolve) => setTimeout(resolve, 20)); + + if (interrupt.kind !== "full") throw new Error("expected full tool"); + await interrupt.handler( + { id: "int-1", name: "interrupt_agent", arguments: { target: id } }, + new AbortController().signal, + ); + + const early = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + expect((early.results as { status: string }[])[0]!.status).toBe("interrupted"); + expect((early.results as { report?: string }[])[0]!.report).toBeUndefined(); + + settle.resolve({ + report: "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n", + interrupted: true, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const again = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + const results = again.results as { status: string; report?: string }[]; + expect(results[0]!.status).toBe("interrupted"); + expect(results[0]!.report).toContain("salvage"); + }); }); describe("close_agent unblocks wait_agents", () => { diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index ad78beacf..4c7bc8e9c 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -131,13 +131,19 @@ class FleetRecords { /** * Marks a still-running record interrupted so wait_agents unblocks. - * No-op on an already-terminal id — interrupt must not clobber a collected - * report, and a late interrupt after complete/fail is meaningless. + * No-op on an already-terminal id that is not interrupted — a late + * interrupt after complete/fail is meaningless. A late salvage report may + * still attach to an interrupted record that has none yet (including after + * an early collect), but never overwrites an existing report. */ interrupt(id: string, report?: string): void { const existing = this.records.get(id); if (existing === undefined) return; - if (existing.status === "interrupted" && existing.collected !== true && report !== undefined) { + if ( + existing.status === "interrupted" && + report !== undefined && + existing.report === undefined + ) { existing.report = report; this.notify(); return; diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index ce33e23d3..6d282da25 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -32,6 +32,41 @@ describe("agentProgress", () => { expect(agentProgress({ ...base, status: "cancelled" }, 1000)).toBeNull(); }); + test("an interrupted running session names leftover tools instead of looking busy", () => { + const progress = agentProgress( + { + ...base, + lifecycleStatus: "interrupted", + currentToolName: "run_shell", + currentToolPreview: "bun test", + currentToolStartedAt: 1_000, + lastActivityAt: 1_000, + }, + 91_000, + ); + expect(progress?.stat).toBe("interrupted · bun test still running"); + expect(progress?.working).toBe(false); + expect(progress?.stalled).toBe(false); + }); + + test("an interrupted session with no leftover tool shows plain interrupted", () => { + const progress = agentProgress( + { + ...base, + lifecycleStatus: "interrupted", + currentToolName: null, + currentToolPreview: null, + currentToolStartedAt: null, + lastActivityAt: 1_000, + }, + 91_000, + ); + expect(progress?.stat).toBe("interrupted"); + expect(progress?.stat).not.toContain("still running"); + expect(progress?.working).toBe(false); + expect(progress?.stalled).toBe(false); + }); + test("a running session reports elapsed time and its current tool", () => { const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000); expect(progress).toEqual({ diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 228160c0d..2781dc0be 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -16,6 +16,9 @@ /** Minimal session shape this module reads — avoids a hard dep on the store. */ export interface AgentProgressSession { readonly status: "running" | "done" | "failed" | "cancelled"; + /** Present when the strip knows lifecycle independently of TUI status. */ + readonly lifecycleStatus?: + "pending_init" | "running" | "interrupted" | "completed" | "shutdown" | "not_found"; readonly currentToolName: string | null; /** * Bounded subject of the oldest outstanding call (command, path, pattern…), @@ -146,6 +149,17 @@ export function agentProgress( const hasSubject = subject !== null; const state = laneState(session, nowMs, stallMs); + if (session.lifecycleStatus === "interrupted") { + const toolBit = + hasSubject && session.currentToolName !== null ? ` · ${subject} still running` : ""; + return { + stat: `interrupted${toolBit}`, + state, + working: false, + stalled: false, + }; + } + const base = hasSubject ? `${elapsed} · ${subject}` : elapsed; // Never render "quiet" — operator chrome only shows motion (elapsed / tool). // Internal `state` still carries stalled for recovery consumers. diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 01181f3b9..d5f61b765 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -54,6 +54,7 @@ export interface ChromeAgentSession { readonly agentId: string; readonly description: string; readonly status: "running" | "done" | "failed" | "cancelled"; + readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"]; /** Current tool while running (optional detail). */ readonly currentToolName?: string | null; /** @@ -319,6 +320,7 @@ function toProgressSession(session: ChromeAgentSession): AgentProgressSession | if (session.startedAt === undefined) return null; return { status: session.status, + ...(session.lifecycleStatus !== undefined ? { lifecycleStatus: session.lifecycleStatus } : {}), currentToolName: session.currentToolName ?? null, currentToolPreview: session.currentToolPreview ?? null, currentToolStartedAt: session.currentToolStartedAt, @@ -493,6 +495,7 @@ export interface ChromeSessionAgent { readonly id?: string; readonly description: string; readonly status: "running" | "done" | "failed" | "cancelled"; + readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"]; readonly currentToolName?: string | null; readonly currentToolPreview?: string | null; readonly currentToolStartedAt: number | null; @@ -554,6 +557,7 @@ function mapSessionAgents( agentId: agentId.length > 0 ? agentId : "agent", description: a.description, status: a.status, + ...(a.lifecycleStatus !== undefined ? { lifecycleStatus: a.lifecycleStatus } : {}), ...(a.currentToolName !== undefined ? { currentToolName: a.currentToolName } : {}), ...(a.currentToolPreview !== undefined ? { currentToolPreview: a.currentToolPreview } : {}), currentToolStartedAt: a.currentToolStartedAt, diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index 4597a64f6..2e7a6eca5 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -279,6 +279,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise deps.subAgentSessions().map((s) => ({ id: s.id, status: s.status, + lifecycleStatus: s.lifecycleStatus, currentToolName: s.currentToolName, currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 9eb4da9ac..d19b19ab3 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2413,6 +2413,7 @@ export async function runTUI(initialConfig: Config): Promise { id: s.id, description: s.description, status: s.status, + lifecycleStatus: s.lifecycleStatus, currentToolName: s.currentToolName, currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt,