Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/background-subagent-finalization.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions packages/eve/src/cli/dev/tui/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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" });
});
});

Expand Down
41 changes: 39 additions & 2 deletions packages/eve/src/cli/dev/tui/subagent-pump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
for (let i = 0; i < 8; i += 1) await Promise.resolve();
}
Expand All @@ -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 }),
);
Expand Down Expand Up @@ -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" });
});
});

Expand Down
12 changes: 6 additions & 6 deletions packages/eve/src/cli/dev/tui/subagent-pump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -281,7 +281,7 @@ export class SubagentPump {
} finally {
this.#pumps.delete(callId);
}
if (boundaryReached) this.#finalizeRun(callId);
if (boundaryReached) this.#finalizeRun(callId, true);
})();
}

Expand Down Expand Up @@ -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";
Expand All @@ -361,7 +361,7 @@ export class SubagentPump {
}
run.currentSectionKey = null;
this.#sweepPreparingTools(callId, run);
this.#view?.complete({ callId });
this.#view?.complete({ authoritative, callId });
}

/**
Expand Down
30 changes: 29 additions & 1 deletion packages/eve/src/cli/dev/tui/terminal-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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" });
Expand Down
7 changes: 6 additions & 1 deletion packages/eve/src/cli/dev/tui/terminal-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Loading