Skip to content

Commit 0fdf37f

Browse files
committed
fix(agents): repair persisted tool result pairing
1 parent 2ac011b commit 0fdf37f

4 files changed

Lines changed: 358 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
- Control UI/WebChat: focus the composer when users click the visible input chrome and restore larger, labeled desktop composer controls while preserving compact mobile taps. Fixes #45656. Thanks @BunsDev.
1414
- Memory search: stop using chokidar write-stability polling for memory and QMD watchers so large Markdown extraPath trees no longer build up regular file descriptors; changed files now settle through the existing debounced sync queue. Fixes #77327 and #78224. (#81802) Thanks @frankekn, @loyur, and @JanPlessow.
15+
- Agents/session-file repair: move displaced persisted tool results back next to their assistant tool calls and drop orphaned or duplicate tool results before loading JSONL session transcripts, preventing restart-persistent transcript corruption after interrupted tool runs. Fixes #58608. Thanks @funkylazer.
1516

1617
## 2026.5.14
1718

docs/reference/transcript-hygiene.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ read_when:
77
title: "Transcript hygiene"
88
---
99

10-
OpenClaw applies **provider-specific fixes** to transcripts before a run (building model context). Most of these are **in-memory** adjustments used to satisfy strict provider requirements. A separate session-file repair pass may also rewrite stored JSONL before the session is loaded, but only for malformed lines or persisted turns that are invalid durable records. Delivered assistant replies are preserved on disk; provider-specific assistant-prefill stripping happens only while constructing outbound payloads. When a repair occurs, the original file is backed up alongside the session file.
10+
OpenClaw applies **provider-specific fixes** to transcripts before a run (building model context). Most of these are **in-memory** adjustments used to satisfy strict provider requirements. A separate session-file repair pass may also rewrite stored JSONL before the session is loaded, but only for malformed lines or persisted turns that are invalid durable records. The disk pass also repairs invalid persisted tool-result pairing by moving displaced matching tool results next to their assistant tool call and dropping duplicate or orphan tool results; generic missing-result synthesis stays replay-only except for the existing Codex session-file repair. Delivered assistant replies are preserved on disk; provider-specific assistant-prefill stripping happens only while constructing outbound payloads. When a repair occurs, the original file is backed up alongside the session file.
1111

1212
Scope includes:
1313

src/agents/session-file-repair.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,182 @@ describe("repairSessionFileIfNeeded", () => {
482482
expect(after).toBe(original);
483483
});
484484

485+
it("moves displaced persisted tool results next to their owning assistant tool call", async () => {
486+
const { file } = await createTempSessionPath();
487+
const { header, message } = buildSessionHeaderAndMessage();
488+
const toolCallAssistant = {
489+
type: "message",
490+
id: "msg-asst-tc",
491+
parentId: "msg-1",
492+
timestamp: new Date().toISOString(),
493+
message: {
494+
role: "assistant",
495+
content: [{ type: "toolCall", id: "call_1", name: "read", input: { path: "a.txt" } }],
496+
stopReason: "toolUse",
497+
},
498+
};
499+
const customSummary = {
500+
type: "summary",
501+
id: "summary-1",
502+
timestamp: new Date().toISOString(),
503+
summary: "opaque summary blob",
504+
};
505+
const userFollowUp = {
506+
type: "message",
507+
id: "msg-user-2",
508+
parentId: "msg-asst-tc",
509+
timestamp: new Date().toISOString(),
510+
message: { role: "user", content: "follow up" },
511+
};
512+
const displacedToolResult = {
513+
type: "message",
514+
id: "msg-tool-result",
515+
parentId: "custom-parent-to-preserve",
516+
timestamp: new Date().toISOString(),
517+
message: {
518+
role: "toolResult",
519+
toolCallId: "call_1",
520+
toolName: "read",
521+
content: [{ type: "text", text: "file contents" }],
522+
isError: false,
523+
},
524+
};
525+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(customSummary)}\n${JSON.stringify(userFollowUp)}\n${JSON.stringify(displacedToolResult)}\n`;
526+
await fs.writeFile(file, original, "utf-8");
527+
528+
const debug = vi.fn();
529+
const result = await repairSessionFileIfNeeded({ sessionFile: file, debug });
530+
531+
expect(result.repaired).toBe(true);
532+
expect(result.movedToolResults).toBe(1);
533+
await expect(fs.readFile(requireBackupPath(result), "utf-8")).resolves.toBe(original);
534+
expect(requireFirstLogMessage(debug)).toContain("moved 1 tool result(s)");
535+
536+
const repairedLines = (await fs.readFile(file, "utf-8"))
537+
.trimEnd()
538+
.split("\n")
539+
.map((line) => JSON.parse(line));
540+
expect(repairedLines).toEqual([
541+
header,
542+
message,
543+
toolCallAssistant,
544+
displacedToolResult,
545+
customSummary,
546+
userFollowUp,
547+
]);
548+
});
549+
550+
it("drops duplicate persisted tool results after preserving the first matching result", async () => {
551+
const { file } = await createTempSessionPath();
552+
const { header, message } = buildSessionHeaderAndMessage();
553+
const toolCallAssistant = {
554+
type: "message",
555+
id: "msg-asst-tc",
556+
parentId: "msg-1",
557+
timestamp: new Date().toISOString(),
558+
message: {
559+
role: "assistant",
560+
content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }],
561+
stopReason: "toolUse",
562+
},
563+
};
564+
const firstToolResult = {
565+
type: "message",
566+
id: "msg-tool-result-1",
567+
parentId: "msg-asst-tc",
568+
timestamp: new Date().toISOString(),
569+
message: {
570+
role: "toolResult",
571+
toolCallId: "call_1",
572+
toolName: "exec",
573+
content: [{ type: "text", text: "first" }],
574+
isError: false,
575+
},
576+
};
577+
const duplicateToolResult = {
578+
type: "message",
579+
id: "msg-tool-result-2",
580+
parentId: "msg-asst-tc",
581+
timestamp: new Date().toISOString(),
582+
message: {
583+
role: "toolResult",
584+
toolCallId: "call_1",
585+
toolName: "exec",
586+
content: [{ type: "text", text: "duplicate" }],
587+
isError: false,
588+
},
589+
};
590+
const userFollowUp = {
591+
type: "message",
592+
id: "msg-user-2",
593+
parentId: "msg-tool-result-1",
594+
timestamp: new Date().toISOString(),
595+
message: { role: "user", content: "next" },
596+
};
597+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(firstToolResult)}\n${JSON.stringify(userFollowUp)}\n${JSON.stringify(duplicateToolResult)}\n`;
598+
await fs.writeFile(file, original, "utf-8");
599+
600+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
601+
602+
expect(result.repaired).toBe(true);
603+
expect(result.droppedDuplicateToolResults).toBe(1);
604+
expect(result.movedToolResults ?? 0).toBe(0);
605+
606+
const repairedLines = (await fs.readFile(file, "utf-8"))
607+
.trimEnd()
608+
.split("\n")
609+
.map((line) => JSON.parse(line));
610+
expect(repairedLines).toEqual([
611+
header,
612+
message,
613+
toolCallAssistant,
614+
firstToolResult,
615+
userFollowUp,
616+
]);
617+
});
618+
619+
it("drops orphan persisted tool results that have no matching assistant tool call", async () => {
620+
const { file } = await createTempSessionPath();
621+
const { header, message } = buildSessionHeaderAndMessage();
622+
const orphanToolResult = {
623+
type: "message",
624+
id: "msg-tool-result-orphan",
625+
parentId: "missing-assistant",
626+
timestamp: new Date().toISOString(),
627+
message: {
628+
role: "toolResult",
629+
toolCallId: "call_missing",
630+
toolName: "read",
631+
content: [{ type: "text", text: "orphan" }],
632+
isError: false,
633+
},
634+
};
635+
const plainAssistant = {
636+
type: "message",
637+
id: "msg-asst-plain",
638+
parentId: "msg-1",
639+
timestamp: new Date().toISOString(),
640+
message: {
641+
role: "assistant",
642+
content: [{ type: "text", text: "answer" }],
643+
stopReason: "stop",
644+
},
645+
};
646+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(orphanToolResult)}\n${JSON.stringify(plainAssistant)}\n`;
647+
await fs.writeFile(file, original, "utf-8");
648+
649+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
650+
651+
expect(result.repaired).toBe(true);
652+
expect(result.droppedOrphanToolResults).toBe(1);
653+
654+
const repairedLines = (await fs.readFile(file, "utf-8"))
655+
.trimEnd()
656+
.split("\n")
657+
.map((line) => JSON.parse(line));
658+
expect(repairedLines).toEqual([header, message, plainAssistant]);
659+
});
660+
485661
it("inserts missing code-mode tool results before replay repair has to synthesize them", async () => {
486662
const { file } = await createTempSessionPath();
487663
const { header, message } = buildSessionHeaderAndMessage();

0 commit comments

Comments
 (0)