Skip to content

Commit bf41c90

Browse files
committed
fix(subagent): recover terminal transcript before lost context
1 parent 070b045 commit bf41c90

4 files changed

Lines changed: 374 additions & 0 deletions

File tree

src/agents/subagent-registry.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2399,6 +2399,108 @@ describe("subagent registry seam flow", () => {
23992399
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
24002400
});
24012401

2402+
it("reconciles stale active runs from the current private terminal transcript", async () => {
2403+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
2404+
if (request.method === "agent.wait") {
2405+
return { status: "pending" };
2406+
}
2407+
return {};
2408+
});
2409+
2410+
const startedAt = Date.parse("2026-03-24T11:58:00Z");
2411+
const endedAt = startedAt + 222;
2412+
mocks.loadSessionStore.mockReturnValue({
2413+
"agent:main:subagent:child": {
2414+
sessionId: "sess-child",
2415+
updatedAt: startedAt,
2416+
status: "running",
2417+
},
2418+
});
2419+
2420+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-private-transcript-"));
2421+
try {
2422+
const transcriptFile = path.join(tmpDir, "child-run.jsonl");
2423+
await fs.writeFile(
2424+
transcriptFile,
2425+
[
2426+
JSON.stringify({ type: "session", version: 1, id: "sess-child" }),
2427+
JSON.stringify({
2428+
message: {
2429+
role: "assistant",
2430+
content: "stale copied result",
2431+
stopReason: "stop",
2432+
timestamp: startedAt - 1,
2433+
},
2434+
}),
2435+
JSON.stringify({
2436+
message: {
2437+
role: "assistant",
2438+
content: [{ type: "text", text: "private transcript result" }],
2439+
stopReason: "stop",
2440+
timestamp: endedAt,
2441+
},
2442+
}),
2443+
].join("\n") + "\n",
2444+
"utf-8",
2445+
);
2446+
2447+
mod.addSubagentRunForTests({
2448+
runId: "run-private-terminal",
2449+
childSessionKey: "agent:main:subagent:child",
2450+
requesterSessionKey: "agent:main:main",
2451+
requesterDisplayKey: "main",
2452+
task: "settle from private transcript state",
2453+
cleanup: "keep",
2454+
expectsCompletionMessage: true,
2455+
createdAt: startedAt,
2456+
startedAt,
2457+
execution: {
2458+
status: "running",
2459+
startedAt,
2460+
transcriptFile,
2461+
},
2462+
completion: {
2463+
required: true,
2464+
},
2465+
});
2466+
2467+
vi.setSystemTime(new Date("2026-03-24T12:02:00Z"));
2468+
await mod.testing.sweepOnceForTests();
2469+
2470+
await waitForFast(() => {
2471+
const announceParams = findRecordCallArg(
2472+
mocks.runSubagentAnnounceFlow,
2473+
0,
2474+
"private transcript announce",
2475+
(record) => record.childRunId === "run-private-terminal",
2476+
);
2477+
expectRecordFields(
2478+
announceParams.outcome,
2479+
{ status: "ok", endedAt },
2480+
"private transcript announce outcome",
2481+
);
2482+
});
2483+
2484+
const run = mod
2485+
.listSubagentRunsForRequester("agent:main:main")
2486+
.find((entry) => entry.runId === "run-private-terminal");
2487+
expect(run?.endedAt).toBe(endedAt);
2488+
expectRecordFields(
2489+
run?.outcome,
2490+
{
2491+
status: "ok",
2492+
startedAt,
2493+
endedAt,
2494+
elapsedMs: 222,
2495+
},
2496+
"private transcript run outcome",
2497+
);
2498+
expect(run?.completion?.resultText).toBe("private transcript result");
2499+
} finally {
2500+
await fs.rm(tmpDir, { recursive: true, force: true });
2501+
}
2502+
});
2503+
24022504
it("uses session-store start time when sweeping stale explicit-timeout runs", async () => {
24032505
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
24042506
if (request.method === "agent.wait") {

src/agents/subagent-registry.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
import {
4848
ANNOUNCE_EXPIRY_MS,
4949
MAX_ANNOUNCE_RETRY_COUNT,
50+
capFrozenResultText,
5051
reconcileOrphanedRestoredRuns,
5152
reconcileOrphanedRun,
5253
resolveAnnounceRetryDelayMs,
@@ -85,6 +86,7 @@ import type { SubagentRunRecord } from "./subagent-registry.types.js";
8586
import {
8687
loadSubagentSessionEntry,
8788
resolveCompletionFromSessionEntry,
89+
resolveCompletionFromCurrentRunTranscript,
8890
resolveSubagentSessionCompletion,
8991
resolveSubagentSessionStartedAt,
9092
type SubagentSessionStoreCache,
@@ -959,6 +961,38 @@ async function sweepSubagentRuns() {
959961
continue;
960962
}
961963

964+
const transcriptCompletion = await resolveCompletionFromCurrentRunTranscript({
965+
childSessionKey: entry.childSessionKey,
966+
transcriptFile: entry.execution?.transcriptFile,
967+
fallbackEndedAt: now,
968+
notBeforeMs: entry.startedAt ?? entry.createdAt,
969+
startedAt: entry.startedAt,
970+
});
971+
if (transcriptCompletion) {
972+
const completionState = ensureCompletionState(entry);
973+
if (
974+
transcriptCompletion.resultText !== undefined &&
975+
completionState.resultText === undefined
976+
) {
977+
completionState.resultText = capFrozenResultText(transcriptCompletion.resultText);
978+
completionState.capturedAt = now;
979+
}
980+
await completeSubagentRunWithRecovery(
981+
{
982+
runId,
983+
startedAt: transcriptCompletion.startedAt,
984+
endedAt: transcriptCompletion.endedAt,
985+
outcome: transcriptCompletion.outcome,
986+
reason: transcriptCompletion.reason,
987+
sendFarewell: true,
988+
accountId: entry.requesterOrigin?.accountId,
989+
triggerCleanup: true,
990+
},
991+
"sweeper-current-run-transcript-completion",
992+
);
993+
continue;
994+
}
995+
962996
await completeSubagentRunWithRecovery(
963997
{
964998
runId,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { promises as fs } from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { resolveCompletionFromCurrentRunTranscript } from "./subagent-session-reconciliation.js";
6+
7+
describe("subagent session reconciliation", () => {
8+
let tmpDir: string;
9+
10+
beforeEach(async () => {
11+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-reconcile-"));
12+
});
13+
14+
afterEach(async () => {
15+
await fs.rm(tmpDir, { recursive: true, force: true });
16+
});
17+
18+
async function writeTranscript(events: unknown[]): Promise<string> {
19+
const transcriptFile = path.join(tmpDir, "child-run.jsonl");
20+
await fs.writeFile(
21+
transcriptFile,
22+
`${events.map((event) => JSON.stringify(event)).join("\n")}\n`,
23+
"utf-8",
24+
);
25+
return transcriptFile;
26+
}
27+
28+
it("recovers completion from the current run private terminal stop turn", async () => {
29+
const startedAt = Date.parse("2026-03-24T12:00:00Z");
30+
const endedAt = startedAt + 1_234;
31+
const transcriptFile = await writeTranscript([
32+
{ type: "session", version: 1, id: "sess-child" },
33+
{
34+
message: {
35+
role: "assistant",
36+
content: "stale copied answer",
37+
stopReason: "stop",
38+
timestamp: startedAt - 1,
39+
},
40+
},
41+
{
42+
message: {
43+
role: "assistant",
44+
content: [{ type: "text", text: "current child result" }],
45+
stopReason: "stop",
46+
timestamp: endedAt,
47+
},
48+
},
49+
]);
50+
51+
await expect(
52+
resolveCompletionFromCurrentRunTranscript({
53+
childSessionKey: "agent:main:subagent:child",
54+
transcriptFile,
55+
fallbackEndedAt: endedAt + 10_000,
56+
notBeforeMs: startedAt,
57+
startedAt,
58+
}),
59+
).resolves.toEqual({
60+
startedAt,
61+
endedAt,
62+
outcome: { status: "ok" },
63+
reason: "subagent-complete",
64+
resultText: "current child result",
65+
});
66+
});
67+
68+
it("does not recover stale copied output from before the current run", async () => {
69+
const startedAt = Date.parse("2026-03-24T12:00:00Z");
70+
const transcriptFile = await writeTranscript([
71+
{ type: "session", version: 1, id: "sess-child" },
72+
{
73+
message: {
74+
role: "assistant",
75+
content: "old child result",
76+
stopReason: "stop",
77+
timestamp: startedAt - 1,
78+
},
79+
},
80+
]);
81+
82+
await expect(
83+
resolveCompletionFromCurrentRunTranscript({
84+
childSessionKey: "agent:main:subagent:child",
85+
transcriptFile,
86+
fallbackEndedAt: startedAt + 1_000,
87+
notBeforeMs: startedAt,
88+
startedAt,
89+
}),
90+
).resolves.toBeNull();
91+
});
92+
93+
it.each(["error", "aborted", "toolUse"])(
94+
"does not recover a non-success terminal turn with stopReason=%s",
95+
async (stopReason) => {
96+
const startedAt = Date.parse("2026-03-24T12:00:00Z");
97+
const transcriptFile = await writeTranscript([
98+
{ type: "session", version: 1, id: "sess-child" },
99+
{
100+
message: {
101+
role: "assistant",
102+
content: "partial child text",
103+
stopReason,
104+
timestamp: startedAt + 1,
105+
},
106+
},
107+
]);
108+
109+
await expect(
110+
resolveCompletionFromCurrentRunTranscript({
111+
childSessionKey: "agent:main:subagent:child",
112+
transcriptFile,
113+
fallbackEndedAt: startedAt + 10_000,
114+
notBeforeMs: startedAt,
115+
startedAt,
116+
}),
117+
).resolves.toBeNull();
118+
},
119+
);
120+
});

0 commit comments

Comments
 (0)