Skip to content

Commit 32847ec

Browse files
obviyuschenyangjun-xy
authored andcommitted
fix(agents): normalize string tool-result replay (openclaw#98891)
1 parent 4949723 commit 32847ec

2 files changed

Lines changed: 221 additions & 18 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { streamAnthropic } from "../../llm/providers/anthropic.js";
6+
import type { Context, Message, Model } from "../../llm/types.js";
7+
import type { AgentMessage } from "../runtime/index.js";
8+
import { SessionManager } from "./session-manager.js";
9+
10+
const tempPaths: string[] = [];
11+
12+
async function makeTempDir(): Promise<string> {
13+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tool-result-replay-"));
14+
tempPaths.push(dir);
15+
return dir;
16+
}
17+
18+
function toLlmContext(context: { messages: AgentMessage[] }): Context {
19+
const messages = context.messages.filter(
20+
(message): message is Message =>
21+
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
22+
);
23+
return { messages };
24+
}
25+
26+
function makeAnthropicModel(): Model<"anthropic-messages"> {
27+
return {
28+
id: "claude-sonnet-4-6",
29+
name: "Claude Sonnet 4.6",
30+
provider: "anthropic",
31+
api: "anthropic-messages",
32+
baseUrl: "https://api.anthropic.com",
33+
reasoning: true,
34+
input: ["text"],
35+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
36+
contextWindow: 200_000,
37+
maxTokens: 4096,
38+
};
39+
}
40+
41+
async function writeSessionWithToolResultContent(
42+
sessionFile: string,
43+
content: unknown,
44+
): Promise<void> {
45+
const entries = [
46+
{
47+
type: "session",
48+
version: 3,
49+
id: "string-tool-result-session",
50+
timestamp: "2026-07-01T00:00:00.000Z",
51+
cwd: "/tmp/tool-result-replay",
52+
},
53+
{
54+
type: "message",
55+
id: "user-1",
56+
parentId: null,
57+
timestamp: "2026-07-01T00:00:01.000Z",
58+
message: { role: "user", content: "run lookup", timestamp: 1 },
59+
},
60+
{
61+
type: "message",
62+
id: "assistant-1",
63+
parentId: "user-1",
64+
timestamp: "2026-07-01T00:00:02.000Z",
65+
message: {
66+
role: "assistant",
67+
provider: "anthropic",
68+
api: "anthropic-messages",
69+
model: "claude-sonnet-4-6",
70+
stopReason: "toolUse",
71+
timestamp: 2,
72+
usage: {
73+
input: 0,
74+
output: 0,
75+
cacheRead: 0,
76+
cacheWrite: 0,
77+
totalTokens: 0,
78+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
79+
},
80+
content: [{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} }],
81+
},
82+
},
83+
{
84+
type: "message",
85+
id: "tool-result-1",
86+
parentId: "assistant-1",
87+
timestamp: "2026-07-01T00:00:03.000Z",
88+
message: {
89+
role: "toolResult",
90+
toolCallId: "call_1",
91+
toolName: "lookup",
92+
content,
93+
isError: false,
94+
timestamp: 3,
95+
},
96+
},
97+
];
98+
await fs.writeFile(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`);
99+
}
100+
101+
describe("SessionManager tool-result replay", () => {
102+
afterEach(async () => {
103+
await Promise.all(
104+
tempPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
105+
);
106+
});
107+
108+
it("normalizes string tool-result content loaded from JSONL", async () => {
109+
const dir = await makeTempDir();
110+
const sessionFile = path.join(dir, "session.jsonl");
111+
await writeSessionWithToolResultContent(sessionFile, "lookup result text");
112+
113+
const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/tool-result-replay");
114+
const context = sessionManager.buildSessionContext();
115+
const toolResult = context.messages.find((message) => message.role === "toolResult");
116+
if (!toolResult || toolResult.role !== "toolResult") {
117+
throw new Error("tool result message missing");
118+
}
119+
120+
expect(toolResult.content).toEqual([{ type: "text", text: "lookup result text" }]);
121+
});
122+
123+
it("replays string tool-result JSONL content as Anthropic tool text", async () => {
124+
const dir = await makeTempDir();
125+
const sessionFile = path.join(dir, "session.jsonl");
126+
await writeSessionWithToolResultContent(sessionFile, "lookup result text");
127+
const context = SessionManager.open(
128+
sessionFile,
129+
dir,
130+
"/tmp/tool-result-replay",
131+
).buildSessionContext();
132+
133+
let capturedPayload: unknown;
134+
const stream = streamAnthropic(makeAnthropicModel(), toLlmContext(context), {
135+
apiKey: "sk-ant-provider",
136+
onPayload: (payload) => {
137+
capturedPayload = payload;
138+
throw new Error("stop before network");
139+
},
140+
});
141+
142+
await stream.result();
143+
144+
const payload = capturedPayload as {
145+
messages: Array<{
146+
role: string;
147+
content: Array<{ type?: unknown; content?: unknown }>;
148+
}>;
149+
};
150+
const toolResultBlock = payload.messages
151+
.flatMap((message) => message.content)
152+
.find((block) => block.type === "tool_result");
153+
154+
expect(toolResultBlock?.content).toBe("lookup result text");
155+
});
156+
157+
it("replays object tool-result JSONL content as structured Anthropic tool text", async () => {
158+
const dir = await makeTempDir();
159+
const sessionFile = path.join(dir, "session.jsonl");
160+
const content = { output: "status card text" };
161+
await writeSessionWithToolResultContent(sessionFile, content);
162+
163+
const context = SessionManager.open(
164+
sessionFile,
165+
dir,
166+
"/tmp/tool-result-replay",
167+
).buildSessionContext();
168+
const toolResult = context.messages.find((message) => message.role === "toolResult");
169+
if (!toolResult || toolResult.role !== "toolResult") {
170+
throw new Error("tool result message missing");
171+
}
172+
expect(toolResult.content).toEqual([content]);
173+
174+
let capturedPayload: unknown;
175+
const stream = streamAnthropic(makeAnthropicModel(), toLlmContext(context), {
176+
apiKey: "sk-ant-provider",
177+
onPayload: (payload) => {
178+
capturedPayload = payload;
179+
throw new Error("stop before network");
180+
},
181+
});
182+
183+
await stream.result();
184+
185+
const payload = capturedPayload as {
186+
messages: Array<{
187+
role: string;
188+
content: Array<{ type?: unknown; content?: unknown }>;
189+
}>;
190+
};
191+
const toolResultBlock = payload.messages
192+
.flatMap((message) => message.content)
193+
.find((block) => block.type === "tool_result");
194+
195+
expect(String(toolResultBlock?.content)).toContain("status card text");
196+
});
197+
});

src/agents/sessions/session-manager.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -348,22 +348,7 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
348348

349349
/** Exported for compaction.test.ts */
350350
export function parseSessionEntries(content: string): FileEntry[] {
351-
const entries: FileEntry[] = [];
352-
const lines = content.trim().split("\n");
353-
354-
for (const line of lines) {
355-
if (!line.trim()) {
356-
continue;
357-
}
358-
try {
359-
const entry = JSON.parse(line) as FileEntry;
360-
entries.push(entry);
361-
} catch {
362-
// Skip malformed lines
363-
}
364-
}
365-
366-
return entries;
351+
return parseJsonlEntries(content);
367352
}
368353

369354
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
@@ -740,7 +725,7 @@ function rememberAppendedSessionEntry(
740725
const persistedEntry = JSON.parse(
741726
serializedAppend.startsWith("\n") ? serializedAppend.slice(1) : serializedAppend,
742727
) as FileEntry;
743-
cached.entries.push(freezeFileEntry(persistedEntry));
728+
cached.entries.push(freezeFileEntry(normalizeLoadedFileEntry(persistedEntry)));
744729
cached.snapshot = snapshot;
745730
cached.endsWithNewline = true;
746731
sessionEntriesCache.delete(resolvedPath);
@@ -959,7 +944,7 @@ function parseJsonlEntries(content: string): FileEntry[] {
959944
}
960945
try {
961946
const entry = JSON.parse(line) as FileEntry;
962-
entries.push(entry);
947+
entries.push(normalizeLoadedFileEntry(entry));
963948
} catch {
964949
// Skip malformed lines
965950
}
@@ -968,6 +953,27 @@ function parseJsonlEntries(content: string): FileEntry[] {
968953
return entries;
969954
}
970955

956+
function normalizeLoadedFileEntry(entry: FileEntry): FileEntry {
957+
if (!isJsonRecord(entry) || entry.type !== "message" || !isJsonRecord(entry.message)) {
958+
return entry;
959+
}
960+
// Persisted JSONL is untrusted: shapes may predate the current Message type,
961+
// so normalize through a record view instead of the declared union.
962+
const message: Record<string, unknown> = entry.message;
963+
if (message.role !== "toolResult") {
964+
return entry;
965+
}
966+
// Replayed JSONL can carry string or single-record tool results while
967+
// downstream providers require block arrays. Without this, strings replay as
968+
// empty output and records hit non-array replay assumptions.
969+
if (typeof message.content === "string") {
970+
message.content = [{ type: "text", text: message.content }];
971+
} else if (isJsonRecord(message.content)) {
972+
message.content = [message.content];
973+
}
974+
return entry;
975+
}
976+
971977
function hasReadableSessionHeader(entries: FileEntry[]): boolean {
972978
const header = entries[0];
973979
return header?.type === "session" && typeof (header as { id?: unknown }).id === "string";

0 commit comments

Comments
 (0)