diff --git a/.changeset/background-subagent-finalization.md b/.changeset/background-subagent-finalization.md new file mode 100644 index 000000000..7d3b678f9 --- /dev/null +++ b/.changeset/background-subagent-finalization.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Finalize completed background subagent sections immediately when the child stream reaches a success or failure boundary. Completed sections remain in transcript history but no longer occupy or redraw the live prompt region. diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index 82e890726..2c88a4254 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -2437,7 +2437,7 @@ describe("EveTUIRunner renderer teardown", () => { upsertTool: vi.fn(), removeTool: vi.fn(), markChildToolCallId: vi.fn(), - complete: (update: { callId: string }) => { + complete: (update: { authoritative: boolean; callId: string }) => { completeSubagent(update); completed.resolve(); }, @@ -2467,7 +2467,7 @@ describe("EveTUIRunner renderer teardown", () => { await runner.run(); await completed.promise; - expect(completeSubagent).toHaveBeenCalledWith({ callId: "call-child" }); + expect(completeSubagent).toHaveBeenCalledWith({ authoritative: true, callId: "call-child" }); }); it("does not settle a subagent section when completed carries a background receipt", async () => { @@ -3503,7 +3503,7 @@ describe("EveTUIRunner cancelled-turn subagent settling", () => { expect(view.begin).toHaveBeenCalledWith({ callId: "call-1", name: "researcher" }); // `subagent.completed` never arrives for a cancelled delegation — the // cancellation itself must close the section. - expect(view.complete).toHaveBeenCalledWith({ callId: "call-1" }); + expect(view.complete).toHaveBeenCalledWith({ authoritative: true, callId: "call-1" }); }); }); diff --git a/packages/eve/src/cli/dev/tui/subagent-pump.test.ts b/packages/eve/src/cli/dev/tui/subagent-pump.test.ts index f6617a925..e4a722760 100644 --- a/packages/eve/src/cli/dev/tui/subagent-pump.test.ts +++ b/packages/eve/src/cli/dev/tui/subagent-pump.test.ts @@ -97,6 +97,16 @@ function boundaryEvent(index: number): MessageStreamEvent { ); } +function failedBoundaryEvent(index: number): MessageStreamEvent { + return stampTestEvent( + { + type: "session.failed", + data: { code: "SESSION_FAILED", message: "child failed", sessionId: "child_call-1" }, + } as UnstampedMessageStreamEvent, + index, + ); +} + async function settleAsyncWork(): Promise { for (let i = 0; i < 8; i += 1) await Promise.resolve(); } @@ -120,7 +130,7 @@ describe("SubagentPump.settleAll", () => { // The parent turn is cancelled: sections settle and the stream stops. pump.settleAll(); - expect(view.complete).toHaveBeenCalledWith({ callId: "call-1" }); + expect(view.complete).toHaveBeenCalledWith({ authoritative: true, callId: "call-1" }); expect(view.upsertStep).toHaveBeenCalledWith( expect.objectContaining({ callId: "call-1", finalized: true }), ); @@ -161,7 +171,34 @@ describe("SubagentPump background receipts", () => { expect(view.upsertStep).toHaveBeenCalledWith( expect.objectContaining({ callId: "call-1", reasoning: "still working" }), ); - expect(view.complete).toHaveBeenCalledWith({ callId: "call-1" }); + expect(view.complete).toHaveBeenCalledWith({ authoritative: true, callId: "call-1" }); + }); + + it("treats a child failure boundary as authoritative completion", async () => { + const child = pushableChildStream(); + const client = new Client({ host: "http://localhost:3000" }); + vi.spyOn(client, "session").mockReturnValue({ + stream: (options?: { signal?: AbortSignal }) => child.stream(options), + } as never); + const view = fakeView(); + const pump = new SubagentPump({ client, view, formatActionResultError: () => "failed" }); + + pump.begin(subagentCalled("call-1")); + pump.background("call-1"); + child.push(failedBoundaryEvent(0)); + await settleAsyncWork(); + + expect(view.complete).toHaveBeenCalledWith({ authoritative: true, callId: "call-1" }); + }); + + it("keeps parent completion fallback non-authoritative for late child events", () => { + const view = fakeView(); + const pump = new SubagentPump({ view, formatActionResultError: () => "failed" }); + + pump.begin(subagentCalled("call-1")); + pump.settle("call-1"); + + expect(view.complete).toHaveBeenCalledWith({ authoritative: false, callId: "call-1" }); }); }); diff --git a/packages/eve/src/cli/dev/tui/subagent-pump.ts b/packages/eve/src/cli/dev/tui/subagent-pump.ts index 3c7fe5b0b..cef124dbb 100644 --- a/packages/eve/src/cli/dev/tui/subagent-pump.ts +++ b/packages/eve/src/cli/dev/tui/subagent-pump.ts @@ -36,7 +36,7 @@ export interface SubagentView { /** Drops a child tool row whose call never materialized. */ removeTool(update: { callId: string; childCallId: string }): void; /** Marks a call complete so its section collapses on `└ Done…`. */ - complete(update: { callId: string }): void; + complete(update: { authoritative: boolean; callId: string }): void; /** Suppresses the parent-level tool row for a child-owned call id. */ markChildToolCallId(callId: string): void; } @@ -176,7 +176,7 @@ export class SubagentPump { * (the parent and child streams are independent HTTP connections). */ settle(callId: string): void { - this.#finalizeRun(callId); + this.#finalizeRun(callId, false); } /** @@ -208,7 +208,7 @@ export class SubagentPump { */ settleAll(): void { for (const callId of this.#runs.keys()) { - this.#finalizeRun(callId); + this.#finalizeRun(callId, true); } for (const controller of this.#pumps.values()) { controller.abort(); @@ -281,7 +281,7 @@ export class SubagentPump { } finally { this.#pumps.delete(callId); } - if (boundaryReached) this.#finalizeRun(callId); + if (boundaryReached) this.#finalizeRun(callId, true); })(); } @@ -342,7 +342,7 @@ export class SubagentPump { * the idempotency authority — the child's turn boundary and the parent's * `subagent.completed` can both land here. */ - #finalizeRun(callId: string): void { + #finalizeRun(callId: string, authoritative: boolean): void { const run = this.#runs.get(callId); if (!run || run.status === "settled") return; run.status = "settled"; @@ -361,7 +361,7 @@ export class SubagentPump { } run.currentSectionKey = null; this.#sweepPreparingTools(callId, run); - this.#view?.complete({ callId }); + this.#view?.complete({ authoritative, callId }); } /** diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index b32d6f905..d1d0c9f59 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -1294,7 +1294,7 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("Fetched https://one.example"); expect(screen.snapshot()).not.toContain("Done"); - renderer.completeSubagent({ callId: "s1" }); + renderer.completeSubagent({ authoritative: true, callId: "s1" }); const snapshot = screen.snapshot(); // Completed: the corner reports Done with the counted footnote and the // children fold away — the parent's reply carries the conclusion. @@ -1304,6 +1304,34 @@ describe("TerminalRenderer (inline scrollback)", () => { renderer.shutdown(); }); + it("commits an authoritative background completion out of the live prompt region", async () => { + const { screen, input, renderer } = makeRenderer(); + renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" }); + renderer.beginSubagent({ callId: "s1", name: "researcher" }); + renderer.backgroundSubagent({ callId: "s1" }); + renderer.upsertSubagentTool({ + callId: "s1", + subagentName: "researcher", + childCallId: "dig-1", + toolName: "dig", + input: { phase: "sources" }, + status: "done", + output: { ok: true }, + }); + + renderer.completeSubagent({ authoritative: true, callId: "s1" }); + const outputAfterCommit = screen.rawOutput().length; + const prompt = renderer.readPrompt(); + input.type("next message"); + + // Prompt repaint must not redraw the completed subagent cohort: it has + // moved to immutable scrollback and no longer occupies the live region. + expect(screen.rawOutput().slice(outputAfterCommit)).not.toContain("subagent(researcher)"); + input.enter(); + expect(await prompt).toBe("next message"); + renderer.shutdown(); + }); + it("renders parallel calls to the same subagent as ordinal-numbered sections", async () => { const { screen, renderer } = makeRenderer(); renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" }); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index e9be2d5e3..0b8aba44c 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -1497,11 +1497,16 @@ export class TerminalRenderer implements AgentTUIRenderer { * section's closing corner reports `Done`. The header stays live until the * turn finalizes (committing mid-turn would freeze its child window). */ - completeSubagent(update: { callId: string }): void { + completeSubagent(update: { authoritative: boolean; callId: string }): void { const header = this.#blockById.get(subagentHeaderId(update.callId)); if (header === undefined) return; header.status = "done"; this.#backgroundSubagentCallIds.delete(update.callId); + if (update.authoritative) { + for (const block of this.#blocks) { + if (block.subagentCallId === update.callId) block.live = false; + } + } this.#paint(); }