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-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Keep background subagent activity folded under its original TUI section after the parent turn continues.
56 changes: 56 additions & 0 deletions packages/eve/src/cli/dev/tui/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2291,6 +2291,7 @@ describe("EveTUIRunner renderer teardown", () => {
}),
subagents: {
begin: vi.fn(),
background: vi.fn(),
upsertStep: vi.fn(),
upsertTool: vi.fn(),
removeTool: vi.fn(),
Expand Down Expand Up @@ -2328,6 +2329,60 @@ describe("EveTUIRunner renderer teardown", () => {
expect(completeSubagent).toHaveBeenCalledWith({ callId: "call-child" });
});

it("does not settle a subagent section when completed carries a background receipt", async () => {
const backgroundSubagent = vi.fn();
const completeSubagent = vi.fn();
const runner = new EveTUIRunner({
name: "Weather Agent",
renderer: fakeRenderer({
readPrompt: vi.fn().mockResolvedValueOnce("delegate").mockResolvedValueOnce(undefined),
renderStream: vi.fn(async (result) => {
for await (const event of result.events as AsyncIterable<unknown>) void event;
}),
subagents: {
begin: vi.fn(),
background: backgroundSubagent,
upsertStep: vi.fn(),
upsertTool: vi.fn(),
removeTool: vi.fn(),
markChildToolCallId: vi.fn(),
complete: completeSubagent,
},
}),
session: sessionYielding([
{
type: "subagent.called",
data: {
callId: "call-child",
childSessionId: "child-session",
name: "researcher",
sequence: 0,
sessionId: "parent-session",
toolName: "researcher",
turnId: "turn-parent",
workflowId: "workflow-parent",
},
},
{
type: "subagent.completed",
data: {
backgroundTask: { status: "working", taskId: "task_123" },
callId: "call-child",
output: '{"status":"working","taskId":"task_123"}',
subagentName: "researcher",
},
},
{ type: "turn.completed", data: { sequence: 0, turnId: "turn-parent" } },
{ type: "session.waiting", data: { wait: "next-user-message" } },
]),
});

await runner.run();

expect(backgroundSubagent).toHaveBeenCalledWith({ callId: "call-child" });
expect(completeSubagent).not.toHaveBeenCalled();
});

it("aborts child-session streams when Ctrl-C exits the runner", async () => {
const client = stubClient();
const childSession = client.session({ sessionId: "child-session", streamIndex: 0 });
Expand Down Expand Up @@ -3283,6 +3338,7 @@ describe("EveTUIRunner cancelled-turn subagent settling", () => {
]);
const view = {
begin: vi.fn(),
background: vi.fn(),
upsertStep: vi.fn(),
upsertTool: vi.fn(),
removeTool: vi.fn(),
Expand Down
9 changes: 8 additions & 1 deletion packages/eve/src/cli/dev/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,7 @@ export class EveTUIRunner {
pendingInputRequests: this.#pendingInputRequests,
turnState,
onSubagentCalled: (called) => this.#subagentPump.begin(called),
onSubagentBackgrounded: (callId) => this.#subagentPump.background(callId),
onSubagentCompleted: (callId) => this.#subagentPump.settle(callId),
// A cancelled turn cancels its pending descendants server-side;
// settle their sections and stop their child streams so stale
Expand Down Expand Up @@ -1765,6 +1766,7 @@ type EveStreamTranslatorInput = {
pendingInputRequests: Map<string, InputRequest>;
turnState: AgentTUITurnState;
onSubagentCalled?: (event: SubagentCalledStreamEvent) => void;
onSubagentBackgrounded?: (callId: string) => void;
onSubagentCompleted?: (callId: string) => void;
onTurnCancelled?: () => void;
onConnectionAuthRequired?: (event: AuthorizationRequiredStreamEvent) => void;
Expand All @@ -1791,6 +1793,7 @@ async function* eveEventsToTUIStream(
pendingInputRequests,
turnState,
onSubagentCalled,
onSubagentBackgrounded,
onSubagentCompleted,
onTurnCancelled,
onConnectionAuthRequired,
Expand Down Expand Up @@ -2147,7 +2150,11 @@ async function* eveEventsToTUIStream(

case "subagent.completed": {
const completed = event as SubagentCompletedStreamEvent;
onSubagentCompleted?.(completed.data.callId);
if (completed.data.backgroundTask === undefined) {
onSubagentCompleted?.(completed.data.callId);
} else {
onSubagentBackgrounded?.(completed.data.callId);
}
break;
}

Expand Down
28 changes: 28 additions & 0 deletions packages/eve/src/cli/dev/tui/subagent-pump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SubagentPump, type SubagentView } from "./subagent-pump.js";
function fakeView(): SubagentView {
return {
begin: vi.fn(),
background: vi.fn(),
upsertStep: vi.fn(),
upsertTool: vi.fn(),
removeTool: vi.fn(),
Expand Down Expand Up @@ -137,6 +138,33 @@ describe("SubagentPump.settleAll", () => {
});
});

describe("SubagentPump background receipts", () => {
it("keeps the section open until the child stream reaches its own boundary", 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");

expect(view.background).toHaveBeenCalledWith({ callId: "call-1" });
expect(view.complete).not.toHaveBeenCalled();

child.push(reasoningEvent("still working", 0));
child.push(boundaryEvent(1));
await settleAsyncWork();

expect(view.upsertStep).toHaveBeenCalledWith(
expect.objectContaining({ callId: "call-1", reasoning: "still working" }),
);
expect(view.complete).toHaveBeenCalledWith({ callId: "call-1" });
});
});

describe("SubagentPump child stream replay", () => {
it("folds a replayed child transcript in once when the pump restarts", async () => {
const transcript = [reasoningEvent("looked up the forecast", 0), boundaryEvent(1)];
Expand Down
13 changes: 13 additions & 0 deletions packages/eve/src/cli/dev/tui/subagent-pump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { isAbortLikeError } from "./errors.js";
export interface SubagentView {
/** Opens a call's section the moment its dispatch is announced. */
begin(update: { callId: string; name: string }): void;
/** Keeps a receipt-returned background call mutable across parent turns. */
background(update: { callId: string }): void;
upsertStep(update: SubagentStepUpdate): void;
upsertTool(update: SubagentToolUpdate): void;
/** Drops a child tool row whose call never materialized. */
Expand Down Expand Up @@ -177,6 +179,17 @@ export class SubagentPump {
this.#finalizeRun(callId);
}

/**
* The originating call returned a task receipt, not the child's result.
* Keep the section open until the child stream reaches its own boundary.
* A child that already settled before the receipt raced in stays settled.
*/
background(callId: string): void {
const run = this.#runs.get(callId);
if (run === undefined || run.status === "settled") return;
this.#view?.background({ callId });
}

abortAll(): void {
for (const controller of this.#pumps.values()) {
controller.abort();
Expand Down
48 changes: 48 additions & 0 deletions packages/eve/src/cli/dev/tui/terminal-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,54 @@ describe("TerminalRenderer (inline scrollback)", () => {
renderer.shutdown();
});

it("keeps late background child output inside its section across parent turns", async () => {
const { screen, renderer } = makeRenderer();
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
renderer.beginSubagent({ callId: "s1", name: "researcher" });
renderer.backgroundSubagent({ callId: "s1" });

await renderer.renderStream(
streamOf([
{ type: "assistant-delta", id: "parent-1", delta: "Research task started." },
{ type: "assistant-complete", id: "parent-1" },
{ type: "finish" },
]),
{ continueSession: true },
);
await renderer.renderStream(
streamOf([
{ type: "assistant-delta", id: "parent-2", delta: "It is still running." },
{ type: "assistant-complete", id: "parent-2" },
{ type: "finish" },
]),
{ continueSession: true, submittedPrompt: "cool" },
);

// The child emits after both parent turns. It still inserts beside its
// call's header, not at the current transcript edge beneath parent-2.
renderer.upsertSubagentTool({
callId: "s1",
subagentName: "researcher",
childCallId: "dig-1",
toolName: "dig",
input: { phase: "sources" },
status: "executing",
});

const snapshot = screen.snapshot();
const header = snapshot.indexOf("※ subagent(researcher)");
const child = snapshot.indexOf("dig");
const firstParent = snapshot.indexOf("Research task started.");
const user = snapshot.indexOf("cool");
const secondParent = snapshot.indexOf("It is still running.");
expect(firstParent).toBeGreaterThan(-1);
expect(user).toBeGreaterThan(firstParent);
expect(secondParent).toBeGreaterThan(user);
expect(header).toBeGreaterThan(secondParent);
expect(child).toBeGreaterThan(header);
renderer.shutdown();
});

it("swaps a dispatch's preparing placeholder for the section header", async () => {
const { screen, renderer } = makeRenderer();
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
Expand Down
67 changes: 64 additions & 3 deletions packages/eve/src/cli/dev/tui/terminal-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ export class TerminalRenderer implements AgentTUIRenderer {
#updateSequence = 0;
/** Call ids per subagent name, for the sections' ordinal subtitles. */
readonly #subagentCallsByName = new Map<string, string[]>();
/** Background sections kept at the live edge until their child boundary. */
readonly #backgroundSubagentCallIds = new Set<string>();
/** Session-local file contents, so write blocks can render real diffs. */
readonly #fileContents = new FileContentCache();
readonly #subagentHeaders = new Set<string>();
Expand Down Expand Up @@ -1304,7 +1306,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
return;
}

this.#upsertBlock({
this.#upsertSubagentBlock({
id: subagentStepSectionId(update.callId, update.sectionKey),
kind: "subagent-step",
subagentCallId: update.callId,
Expand Down Expand Up @@ -1380,7 +1382,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
} else if (update.errorText !== undefined) {
block.result = stripTerminalControls(update.errorText);
}
this.#upsertBlock(block);
this.#upsertSubagentBlock(block);
this.#syncSubagentChildLiveness(update.callId);
this.#paint();
}
Expand Down Expand Up @@ -1410,6 +1412,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
*/
readonly subagents: SubagentView = {
begin: (update) => this.beginSubagent(update),
background: (update) => this.backgroundSubagent(update),
upsertStep: (update) => this.upsertSubagentStep(update),
upsertTool: (update) => this.upsertSubagentTool(update),
removeTool: (update) => this.removeSubagentTool(update),
Expand All @@ -1432,6 +1435,21 @@ export class TerminalRenderer implements AgentTUIRenderer {
this.#paint();
}

/**
* A background receipt closes the model tool call, not the child. Mark the
* header running so turn finalization cannot commit immutable scrollback
* before the child pump has folded in its later events.
*/
backgroundSubagent(update: { callId: string }): void {
const header = this.#blockById.get(subagentHeaderId(update.callId));
if (header === undefined || this.#committedIds.has(subagentHeaderId(update.callId))) return;
header.status = "running";
header.live = true;
header.updateSeq = ++this.#updateSequence;
this.#backgroundSubagentCallIds.add(update.callId);
this.#paint();
}

/**
* Marks a subagent call complete — its final message has arrived — so the
* section's closing corner reports `Done`. The header stays live until the
Expand All @@ -1441,6 +1459,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
const header = this.#blockById.get(subagentHeaderId(update.callId));
if (header === undefined) return;
header.status = "done";
this.#backgroundSubagentCallIds.delete(update.callId);
this.#paint();
}

Expand Down Expand Up @@ -1567,6 +1586,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
this.#childToolCallIds.clear();
this.#parentToolBlockIds.clear();
this.#subagentHeaders.clear();
this.#backgroundSubagentCallIds.clear();
this.#subagentCallsByName.clear();
this.#todoItems = undefined;
this.#todoCommittedSignature = undefined;
Expand Down Expand Up @@ -3068,7 +3088,18 @@ export class TerminalRenderer implements AgentTUIRenderer {
#pushBlock(block: Block) {
if (block.id !== this.#devRebuild?.id) this.#settleDevRebuildStatus();
block.updateSeq = ++this.#updateSequence;
this.#blocks.push(block);
const isBackgroundChild =
block.subagentCallId !== undefined &&
this.#backgroundSubagentCallIds.has(block.subagentCallId);
const backgroundIndex = isBackgroundChild
? -1
: this.#blocks.findIndex(
(candidate) =>
candidate.subagentCallId !== undefined &&
this.#backgroundSubagentCallIds.has(candidate.subagentCallId),
);
if (backgroundIndex < 0) this.#blocks.push(block);
else this.#blocks.splice(backgroundIndex, 0, block);
if (block.id) this.#blockById.set(block.id, block);
}

Expand Down Expand Up @@ -3200,6 +3231,36 @@ export class TerminalRenderer implements AgentTUIRenderer {
this.#pushBlock(block);
}

/**
* Inserts a new child beside the rest of its call's cohort instead of at
* the transcript's live edge. Background children can emit after parent
* and user blocks from later turns; arrival order must not split their
* section.
*/
#upsertSubagentBlock(block: Block) {
if (block.id && this.#committedIds.has(block.id)) return;
const existing = block.id ? this.#blockById.get(block.id) : undefined;
if (existing !== undefined) {
Object.assign(existing, block);
existing.updateSeq = ++this.#updateSequence;
return;
}

const callId = block.subagentCallId;
if (callId === undefined) {
this.#pushBlock(block);
return;
}
if (block.id !== this.#devRebuild?.id) this.#settleDevRebuildStatus();
block.updateSeq = ++this.#updateSequence;
let anchor = -1;
for (let index = 0; index < this.#blocks.length; index += 1) {
if (this.#blocks[index]?.subagentCallId === callId) anchor = index;
}
this.#blocks.splice(anchor < 0 ? this.#blocks.length : anchor + 1, 0, block);
if (block.id !== undefined) this.#blockById.set(block.id, block);
}

#removeBlock(id: string) {
this.#blocks = this.#blocks.filter((candidate) => candidate.id !== id);
this.#blockById.delete(id);
Expand Down
Loading