Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunSubAgentResult>();
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", () => {
Expand Down
12 changes: 9 additions & 3 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions src/tui/agent-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
14 changes: 14 additions & 0 deletions src/tui/agent-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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…),
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/tui/chrome-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/tui/runner-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
deps.subAgentSessions().map((s) => ({
id: s.id,
status: s.status,
lifecycleStatus: s.lifecycleStatus,
currentToolName: s.currentToolName,
currentToolPreview: s.currentToolPreview,
currentToolStartedAt: s.currentToolStartedAt,
Expand Down
1 change: 1 addition & 0 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
id: s.id,
description: s.description,
status: s.status,
lifecycleStatus: s.lifecycleStatus,
currentToolName: s.currentToolName,
currentToolPreview: s.currentToolPreview,
currentToolStartedAt: s.currentToolStartedAt,
Expand Down
Loading