Skip to content

Commit 633a04a

Browse files
committed
fix(agents): recover genuine terminal child result on lost-context sweep
The registry sweeper labeled a stale, context-lost subagent run as "failed: subagent run lost active execution context" even when the child had delivered a genuine final result, so a parent orchestrating with sessions_spawn / sessions_yield saw successful work as a plain failure. Before emitting the lost-context error, reconcile the child transcript with a strict terminal-result predicate that reuses the existing sessions_yield -aware snapshot: recover as ok only when there is final visible assistant text and the child is not parked waiting. Tool-call-only histories and yield waits keep the lost-context error, and a transcript-read failure is caught so the sweep still settles the run. Fixes #90299.
1 parent 8047350 commit 633a04a

4 files changed

Lines changed: 425 additions & 5 deletions

File tree

src/agents/subagent-announce-output.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
buildCompactAnnounceStatsLine,
88
buildChildCompletionFindings,
99
readSubagentOutput,
10+
readTerminalChildResult,
11+
resolveTerminalChildResultText,
1012
} from "./subagent-announce-output.js";
1113

1214
type CallGateway = typeof import("../gateway/call.js").callGateway;
@@ -415,3 +417,137 @@ describe("applySubagentWaitOutcome", () => {
415417
});
416418
});
417419
});
420+
421+
function assistantTextTurn(text: string) {
422+
return {
423+
role: "assistant",
424+
stopReason: "stop",
425+
content: [{ type: "text", text }],
426+
};
427+
}
428+
429+
function toolCallOnlyTurn(name = "read") {
430+
return {
431+
role: "assistant",
432+
stopReason: "toolUse",
433+
content: [{ type: "toolCall", id: `call-${name}`, name, arguments: {} }],
434+
};
435+
}
436+
437+
describe("resolveTerminalChildResultText", () => {
438+
it("returns the final visible assistant result for a genuine terminal turn", () => {
439+
expect(
440+
resolveTerminalChildResultText([assistantTextTurn("# ARCHITECTURE.md\nDesign complete.")]),
441+
).toBe("# ARCHITECTURE.md\nDesign complete.");
442+
});
443+
444+
it("returns undefined for a toolUse turn that has visible pre-tool text plus a tool call", () => {
445+
// Regression for ClawSweeper P1 on #92791: a normal assistant turn can carry
446+
// progress text AND a tool call (stopReason "toolUse"); the model expected to
447+
// continue after tool results, so that text is NOT a terminal result.
448+
const mixedTurn = {
449+
role: "assistant",
450+
stopReason: "toolUse",
451+
content: [
452+
{ type: "text", text: "Let me read the brief first." },
453+
{ type: "toolCall", id: "call-read", name: "read", arguments: {} },
454+
],
455+
};
456+
expect(resolveTerminalChildResultText([mixedTurn])).toBeUndefined();
457+
});
458+
459+
it("returns undefined when a terminal text turn is followed by a trailing tool-only turn", () => {
460+
// The child produced text but then kept working (more tool calls) → not done.
461+
expect(
462+
resolveTerminalChildResultText([
463+
assistantTextTurn("Draft ready, verifying…"),
464+
toolCallOnlyTurn(),
465+
]),
466+
).toBeUndefined();
467+
});
468+
469+
it("returns undefined for a tool-call-only history (no visible result)", () => {
470+
// Regression for #90299 / PR #90492: the broad display helper would surface a
471+
// synthetic "1 tool call(s) made without visible output." string here, which
472+
// must NOT count as a terminal result.
473+
expect(resolveTerminalChildResultText([toolCallOnlyTurn()])).toBeUndefined();
474+
});
475+
476+
it("returns undefined for a sessions_yield waiting turn", () => {
477+
// Regression for #90299 / PR #91400: a child parked on sessions_yield is
478+
// waiting on descendants, not finished, and must not be recovered as ok.
479+
expect(resolveTerminalChildResultText(sessionsYieldTurn())).toBeUndefined();
480+
});
481+
482+
it("returns the final assistant result that arrives after a sessions_yield wait", () => {
483+
expect(
484+
resolveTerminalChildResultText([
485+
...sessionsYieldTurn(),
486+
assistantTextTurn("Final report ready."),
487+
]),
488+
).toBe("Final report ready.");
489+
});
490+
491+
it("returns undefined for whitespace-only assistant text", () => {
492+
expect(resolveTerminalChildResultText([assistantTextTurn(" ")])).toBeUndefined();
493+
});
494+
495+
it("returns undefined for an empty history", () => {
496+
expect(resolveTerminalChildResultText([])).toBeUndefined();
497+
});
498+
});
499+
500+
describe("readTerminalChildResult", () => {
501+
afterEach(() => {
502+
testing.setDepsForTest();
503+
});
504+
505+
it("reads gateway history and returns the genuine terminal result", async () => {
506+
const deps = installOutputDeps({
507+
messages: [assistantTextTurn("# ARCHITECTURE.md\nDesign complete.")],
508+
});
509+
await expect(readTerminalChildResult("agent:main:subagent:child")).resolves.toBe(
510+
"# ARCHITECTURE.md\nDesign complete.",
511+
);
512+
expect(deps.callGateway).toHaveBeenCalledOnce();
513+
});
514+
515+
it("returns undefined for a tool-call-only history while readSubagentOutput surfaces a summary", async () => {
516+
// Same transcript, two predicates: the display helper still reports a
517+
// tool-call summary, but the strict terminal predicate reports no result —
518+
// this is exactly the boundary the lost-context sweeper must respect.
519+
installOutputDeps({ messages: [toolCallOnlyTurn()] });
520+
await expect(readSubagentOutput("agent:main:subagent:child")).resolves.toBe(
521+
"1 tool call(s) made without visible output.",
522+
);
523+
524+
installOutputDeps({ messages: [toolCallOnlyTurn()] });
525+
await expect(readTerminalChildResult("agent:main:subagent:child")).resolves.toBeUndefined();
526+
});
527+
528+
it("returns undefined for a sessions_yield waiting transcript", async () => {
529+
installOutputDeps({ messages: sessionsYieldTurn() });
530+
await expect(readTerminalChildResult("agent:main:subagent:child")).resolves.toBeUndefined();
531+
});
532+
533+
it("returns undefined for a toolUse turn while readSubagentOutput keeps the display text", async () => {
534+
const mixed = [
535+
{
536+
role: "assistant",
537+
stopReason: "toolUse",
538+
content: [
539+
{ type: "text", text: "Let me read the brief first." },
540+
{ type: "toolCall", id: "call-read", name: "read", arguments: {} },
541+
],
542+
},
543+
];
544+
// Display behavior is intentionally unchanged: it still shows the visible text.
545+
installOutputDeps({ messages: mixed });
546+
await expect(readSubagentOutput("agent:main:subagent:child")).resolves.toBe(
547+
"Let me read the brief first.",
548+
);
549+
// But the lifecycle predicate refuses to treat that pre-tool text as terminal.
550+
installOutputDeps({ messages: mixed });
551+
await expect(readTerminalChildResult("agent:main:subagent:child")).resolves.toBeUndefined();
552+
});
553+
});

src/agents/subagent-announce-output.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ type SubagentOutputSnapshot = {
5454
latestSilentText?: string;
5555
latestToolCallCount?: number;
5656
waitingForContinuation?: boolean;
57+
/**
58+
* Whether the latest assistant activity was a genuine terminal turn: visible
59+
* text with no tool calls and no `toolUse` stop reason. A `toolUse` turn (even
60+
* one carrying visible pre-tool text) and any trailing tool-only turn are
61+
* non-terminal — the model expected to continue after tool results. Used only
62+
* by the lifecycle terminal-result predicate; display selection ignores it.
63+
*/
64+
latestAssistantTurnTerminal?: boolean;
5765
};
5866

5967
type AgentWaitResult = {
@@ -155,6 +163,7 @@ function summarizeSubagentOutputHistory(messages: Array<unknown>): SubagentOutpu
155163
snapshot.latestToolCallCount =
156164
(snapshot.latestToolCallCount ?? 0) + countAssistantToolCalls(message);
157165
snapshot.waitingForContinuation = false;
166+
snapshot.latestAssistantTurnTerminal = false;
158167
previousAssistantCalledYield = false;
159168
continue;
160169
}
@@ -168,6 +177,9 @@ function summarizeSubagentOutputHistory(messages: Array<unknown>): SubagentOutpu
168177
snapshot.latestSilentText = undefined;
169178
snapshot.latestAssistantText = text;
170179
snapshot.waitingForContinuation = false;
180+
const stopReason = (message as { stopReason?: unknown }).stopReason;
181+
snapshot.latestAssistantTurnTerminal =
182+
countAssistantToolCalls(message) === 0 && stopReason !== "toolUse";
171183
previousAssistantCalledYield = false;
172184
continue;
173185
}
@@ -199,11 +211,10 @@ function selectSubagentOutputText(snapshot: SubagentOutputSnapshot): string | un
199211
return undefined;
200212
}
201213

202-
export async function readSubagentOutput(
214+
async function loadSubagentHistoryMessages(
203215
sessionKey: string,
204-
_outcome?: SubagentRunOutcome,
205216
options?: { sessionFile?: string },
206-
): Promise<string | undefined> {
217+
): Promise<unknown[]> {
207218
let messages: unknown[] | undefined;
208219
if (options?.sessionFile) {
209220
const transcriptMessages = await subagentAnnounceOutputDeps.readSessionMessagesAsync(
@@ -225,7 +236,15 @@ export async function readSubagentOutput(
225236
params: { sessionKey, limit: 100 },
226237
})
227238
: undefined;
228-
const sourceMessages = messages ?? (Array.isArray(history?.messages) ? history.messages : []);
239+
return messages ?? (Array.isArray(history?.messages) ? history.messages : []);
240+
}
241+
242+
export async function readSubagentOutput(
243+
sessionKey: string,
244+
_outcome?: SubagentRunOutcome,
245+
options?: { sessionFile?: string },
246+
): Promise<string | undefined> {
247+
const sourceMessages = await loadSubagentHistoryMessages(sessionKey, options);
229248
const snapshot = summarizeSubagentOutputHistory(sourceMessages);
230249
const selected = selectSubagentOutputText(snapshot);
231250
if (selected?.trim()) {
@@ -234,6 +253,53 @@ export async function readSubagentOutput(
234253
return undefined;
235254
}
236255

256+
/**
257+
* Strict terminal-result predicate for lifecycle reconciliation.
258+
*
259+
* Unlike {@link selectSubagentOutputText} / {@link readSubagentOutput} — display
260+
* helpers that also surface a synthetic "N tool call(s) made without visible
261+
* output." summary and silent-reply markers — this returns the child's final
262+
* visible assistant result ONLY when the transcript shows a genuine terminal turn.
263+
* It deliberately returns `undefined` for:
264+
* - `sessions_yield` waiting turns (`waitingForContinuation`) — the child is
265+
* parked waiting on descendants, not finished;
266+
* - `toolUse` assistant turns that carry visible pre-tool text plus a tool
267+
* call — the model expected to continue after tool results, so the text is
268+
* progress, not a final result;
269+
* - tool-call-only histories with no visible assistant text;
270+
* - silent-reply / announce-skip markers (no deliverable result).
271+
*
272+
* Used by the registry sweeper to decide whether a stale, context-lost run
273+
* actually delivered a result before falling back to a lost-context error, so a
274+
* non-terminal run is never recovered as a successful completion.
275+
*/
276+
export function resolveTerminalChildResultText(messages: Array<unknown>): string | undefined {
277+
const snapshot = summarizeSubagentOutputHistory(messages);
278+
if (snapshot.waitingForContinuation) {
279+
return undefined;
280+
}
281+
// A `toolUse` assistant turn (even with visible pre-tool text) and any trailing
282+
// tool-only turn are mid-work, not a finished result — never recover those as ok.
283+
if (snapshot.latestAssistantTurnTerminal !== true) {
284+
return undefined;
285+
}
286+
const text = snapshot.latestAssistantText?.trim();
287+
return text ? text : undefined;
288+
}
289+
290+
/**
291+
* Read a child session and return its final terminal result text, or `undefined`
292+
* when the child has not produced a genuine terminal result (waiting / tool-only /
293+
* silent / empty). Reuses the same history source as {@link readSubagentOutput}.
294+
*/
295+
export async function readTerminalChildResult(
296+
sessionKey: string,
297+
options?: { sessionFile?: string },
298+
): Promise<string | undefined> {
299+
const sourceMessages = await loadSubagentHistoryMessages(sessionKey, options);
300+
return resolveTerminalChildResultText(sourceMessages);
301+
}
302+
237303
export async function readLatestSubagentOutputWithRetry(params: {
238304
sessionKey: string;
239305
maxWaitMs: number;

0 commit comments

Comments
 (0)