Skip to content

Commit 7274e3b

Browse files
committed
fix: only recover lost-context runs with visible assistant text (fixes #90299)
1 parent fd43e06 commit 7274e3b

3 files changed

Lines changed: 177 additions & 23 deletions

File tree

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,99 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
import * as announceOutput from "./subagent-announce-output.js";
2+
import type * as gatewayCallModule from "../gateway/call.js";
33
import {
44
LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
55
resolveStaleActiveSubagentOutcome,
66
} from "./subagent-lost-context-completion.js";
77

8+
const CHILD_SESSION = "agent:main:subagent:child";
9+
10+
function assistantTextMessage(text: string) {
11+
return { role: "assistant", content: [{ type: "text", text }] };
12+
}
13+
14+
function assistantStringMessage(text: string) {
15+
return { role: "assistant", content: text };
16+
}
17+
18+
function toolCallOnlyMessage() {
19+
return {
20+
role: "assistant",
21+
content: [{ type: "tool_use", id: "t1", name: "read", input: {} }],
22+
};
23+
}
24+
825
describe("resolveStaleActiveSubagentOutcome", () => {
9-
beforeEach(() => {
26+
let callGatewaySpy: ReturnType<typeof vi.spyOn>;
27+
28+
beforeEach(async () => {
1029
vi.restoreAllMocks();
30+
callGatewaySpy = vi.spyOn(
31+
(await import("../gateway/call.js")) as typeof gatewayCallModule,
32+
"callGateway",
33+
);
1134
});
1235

13-
it("returns ok when child session has readable assistant output", async () => {
14-
vi.spyOn(announceOutput, "readSubagentOutput").mockResolvedValue(
15-
"# ARCHITECTURE.md\nrelease readiness design",
16-
);
36+
it("returns ok when child session has visible assistant text", async () => {
37+
callGatewaySpy.mockResolvedValue({
38+
messages: [assistantTextMessage("# ARCHITECTURE.md\nrelease readiness design")],
39+
});
1740
await expect(
18-
resolveStaleActiveSubagentOutcome({
19-
childSessionKey: "agent:main:subagent:child",
20-
}),
41+
resolveStaleActiveSubagentOutcome({ childSessionKey: CHILD_SESSION }),
2142
).resolves.toEqual({ status: "ok" });
2243
});
2344

24-
it("returns lost-context error when no readable output is available", async () => {
25-
vi.spyOn(announceOutput, "readSubagentOutput").mockResolvedValue(undefined);
45+
it("returns lost-context error when chat.history returns no messages", async () => {
46+
callGatewaySpy.mockResolvedValue({ messages: [] });
47+
await expect(
48+
resolveStaleActiveSubagentOutcome({ childSessionKey: CHILD_SESSION }),
49+
).resolves.toEqual({
50+
status: "error",
51+
error: LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
52+
});
53+
});
54+
55+
it("returns lost-context error for silent reply token text", async () => {
56+
callGatewaySpy.mockResolvedValue({
57+
messages: [assistantTextMessage("NO_REPLY")],
58+
});
2659
await expect(
27-
resolveStaleActiveSubagentOutcome({
28-
childSessionKey: "agent:main:subagent:child",
29-
}),
60+
resolveStaleActiveSubagentOutcome({ childSessionKey: CHILD_SESSION }),
3061
).resolves.toEqual({
3162
status: "error",
3263
error: LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
3364
});
3465
});
3566

36-
it("returns lost-context error when output is whitespace only", async () => {
37-
vi.spyOn(announceOutput, "readSubagentOutput").mockResolvedValue(" \n ");
67+
it("returns lost-context error when history only contains tool calls without visible output", async () => {
68+
callGatewaySpy.mockResolvedValue({
69+
messages: [toolCallOnlyMessage()],
70+
});
3871
await expect(
39-
resolveStaleActiveSubagentOutcome({
40-
childSessionKey: "agent:main:subagent:child",
41-
}),
72+
resolveStaleActiveSubagentOutcome({ childSessionKey: CHILD_SESSION }),
4273
).resolves.toEqual({
4374
status: "error",
4475
error: LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
4576
});
4677
});
78+
79+
it("returns lost-context error when string-content assistant message is whitespace only", async () => {
80+
callGatewaySpy.mockResolvedValue({
81+
messages: [assistantStringMessage(" \n ")],
82+
});
83+
await expect(
84+
resolveStaleActiveSubagentOutcome({ childSessionKey: CHILD_SESSION }),
85+
).resolves.toEqual({
86+
status: "error",
87+
error: LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
88+
});
89+
});
90+
91+
it("returns ok for assistant message with string content", async () => {
92+
callGatewaySpy.mockResolvedValue({
93+
messages: [assistantStringMessage("result text")],
94+
});
95+
await expect(
96+
resolveStaleActiveSubagentOutcome({ childSessionKey: CHILD_SESSION }),
97+
).resolves.toEqual({ status: "ok" });
98+
});
4799
});

src/agents/subagent-lost-context-completion.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,61 @@
11
/**
22
* Reconcile stale active subagent runs that lost live execution context.
33
*
4-
* When the sweeper cannot resolve terminal state from the session store, readable child
4+
* When the sweeper cannot resolve terminal state from the session store, visible child
55
* assistant output is treated as ground truth — the subagent completed successfully while the
66
* parent still needs the result. Without this reconciliation, the parent receives a plain
77
* failure despite the child having delivered output, causing a lifecycle/result mismatch.
8+
*
9+
* Only visible (non-silent, non-skip) assistant text qualifies for recovery. Tool-call-only
10+
* histories and silent reply tokens keep the existing lost-context error path so the parent
11+
* gets an honest signal.
812
*/
13+
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
14+
import { callGateway } from "../gateway/call.js";
915
import type { SubagentRunOutcome } from "./subagent-announce-output.js";
10-
import { readSubagentOutput } from "./subagent-announce-output.js";
16+
import { isAnnounceSkip } from "./tools/sessions-send-tokens.js";
1117

1218
export const LOST_ACTIVE_EXECUTION_CONTEXT_ERROR = "subagent run lost active execution context";
1319

20+
function extractAssistantVisibleText(message: unknown): string {
21+
if (!message || typeof message !== "object") return "";
22+
const m = message as Record<string, unknown>;
23+
if (m.role !== "assistant") return "";
24+
const content = m.content;
25+
if (typeof content === "string") {
26+
return content.trim();
27+
}
28+
if (Array.isArray(content)) {
29+
return (content as Array<Record<string, unknown>>)
30+
.filter((b) => b?.type === "text" && typeof b.text === "string")
31+
.map((b) => b.text as string)
32+
.join("")
33+
.trim();
34+
}
35+
return "";
36+
}
37+
38+
function hasVisibleAssistantOutput(messages: unknown[]): boolean {
39+
for (const msg of messages) {
40+
const text = extractAssistantVisibleText(msg);
41+
if (!text) continue;
42+
if (isAnnounceSkip(text)) continue;
43+
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) continue;
44+
return true;
45+
}
46+
return false;
47+
}
48+
1449
/** Resolve terminal outcome for a stale active run with no live `agent.run` context. */
1550
export async function resolveStaleActiveSubagentOutcome(params: {
1651
childSessionKey: string;
1752
}): Promise<SubagentRunOutcome> {
18-
const output = await readSubagentOutput(params.childSessionKey);
19-
if (output?.trim()) {
53+
const history = await callGateway({
54+
method: "chat.history",
55+
params: { sessionKey: params.childSessionKey, limit: 100 },
56+
});
57+
const messages: unknown[] = Array.isArray(history?.messages) ? history.messages : [];
58+
if (hasVisibleAssistantOutput(messages)) {
2059
return { status: "ok" };
2160
}
2261
return {

src/agents/subagent-registry.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2432,6 +2432,69 @@ describe("subagent registry seam flow", () => {
24322432
expect(run?.outcome?.error).toBe("subagent run lost active execution context");
24332433
});
24342434

2435+
it("keeps lost-context error when child history only contains tool calls without visible assistant text", async () => {
2436+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
2437+
if (request.method === "agent.wait") {
2438+
return { status: "pending" };
2439+
}
2440+
if (request.method === "chat.history") {
2441+
return {
2442+
messages: [
2443+
{
2444+
role: "assistant",
2445+
content: [{ type: "tool_use", id: "t1", name: "read", input: {} }],
2446+
},
2447+
],
2448+
};
2449+
}
2450+
return {};
2451+
});
2452+
mocks.loadSessionStore.mockReturnValue({
2453+
"agent:main:subagent:child": {
2454+
sessionId: "sess-child",
2455+
updatedAt: 1,
2456+
status: "running",
2457+
},
2458+
});
2459+
2460+
const createdAt = Date.parse("2026-03-24T11:00:00Z");
2461+
vi.setSystemTime(createdAt);
2462+
mod.registerSubagentRun({
2463+
runId: "run-lost-context-tool-only",
2464+
childSessionKey: "agent:main:subagent:child",
2465+
requesterSessionKey: "agent:main:main",
2466+
requesterDisplayKey: "main",
2467+
task: "tool-only subagent",
2468+
cleanup: "keep",
2469+
expectsCompletionMessage: true,
2470+
});
2471+
2472+
vi.setSystemTime(createdAt + 65_000);
2473+
await mod.testing.sweepOnceForTests();
2474+
2475+
await waitForFast(() => {
2476+
const announceParams = findRecordCallArg(
2477+
mocks.runSubagentAnnounceFlow,
2478+
0,
2479+
"lost context tool-only announce",
2480+
(record) => record.childRunId === "run-lost-context-tool-only",
2481+
);
2482+
expectRecordFields(
2483+
announceParams.outcome,
2484+
{
2485+
status: "error",
2486+
error: "subagent run lost active execution context",
2487+
},
2488+
"lost context tool-only announce outcome",
2489+
);
2490+
});
2491+
2492+
const run = mod
2493+
.listSubagentRunsForRequester("agent:main:main")
2494+
.find((entry) => entry.runId === "run-lost-context-tool-only");
2495+
expect(run?.outcome?.status).toBe("error");
2496+
});
2497+
24352498
it("completes a registered run across timing persistence, lifecycle status, and announce cleanup", async () => {
24362499
mod.registerSubagentRun({
24372500
runId: "run-1",

0 commit comments

Comments
 (0)