Skip to content

Commit 5245289

Browse files
authored
fix(agents): trim trailing assistant turns and rewrite blank user messages in session repair (#75606)
* fix(agents): trim trailing assistant turns and rewrite blank user messages in session repair Session-file repair now: - Trims trailing assistant messages so the JSONL never ends on role=assistant, preventing the Anthropic 400 prefill-loop that fires when thinking is enabled. (#75271) - Rewrites blank-only user messages to a synthetic '(continue)' placeholder instead of dropping them, so strict providers (Qwen/mlx-vlm, Anthropic) no longer reject transcripts missing a user turn. (#75313) Closes #75271, closes #75313. * refactor: clean up comments in session-file repair * fix(agents): preserve trailing assistant tool-call turns during session trim Mirror the outbound guard (stripTrailingAssistantPrefillTurns): skip assistant entries containing toolCall/toolUse/functionCall blocks so transcript repair can synthesize missing tool results. Addresses PR review feedback from clawsweeper on #75606.
1 parent 5fbf406 commit 5245289

3 files changed

Lines changed: 343 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
4848
- Voice Call CLI: delegate operational `voicecall` commands to the running Gateway runtime and skip webhook startup during CLI-only plugin loading, preventing webhook port conflicts and `setup --json` hangs. Fixes #72345. Thanks @serrurco and @DougButdorf.
4949
- Agents/pi-embedded-runner: extract the `abortable` provider-call wrapper from `runEmbeddedAttempt` to module scope so its promise handlers no longer close over the run lexical context, releasing transcripts, tool buffers, and subscription callbacks when a provider call hangs past abort. (#74182) Thanks @cjboy007.
5050
- Docker: restore `python3` in the gateway runtime image after the slim-runtime switch. Fixes #75041.
51+
- Agents/session-repair: fix resumed sessions failing with repeated 400 errors on Anthropic and strict OpenAI-compatible providers (Qwen, mlx-vlm) after an interrupted conversation or blank user input. Fixes #75271 and #75313. Thanks @amknight.
5152
- CLI/Voice Call: scope `voicecall` command activation to the Voice Call plugin so setup and smoke checks no longer broad-load unrelated plugin runtimes or hang after printing JSON. Thanks @vincentkoc.
5253
- Doctor/plugins: warn when restrictive `plugins.allow` is paired with wildcard or plugin-owned tool allowlists, making the exclusive plugin allowlist behavior visible before users hit empty callable-tool runs. Refs #58009 and #64982. Thanks @KR-Python and @BKF-Gitty.
5354
- Google Meet/Voice Call: keep Twilio Meet joins in conversation mode and reuse the realtime intro prompt when no voice-call-specific intro is configured, so answered phone bridge calls speak instead of joining silently. Refs #72478. Thanks @DougButdorf.

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

Lines changed: 261 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
5-
import { repairSessionFileIfNeeded } from "./session-file-repair.js";
5+
import { BLANK_USER_FALLBACK_TEXT, repairSessionFileIfNeeded } from "./session-file-repair.js";
66

77
function buildSessionHeaderAndMessage() {
88
const header = {
@@ -100,7 +100,7 @@ describe("repairSessionFileIfNeeded", () => {
100100

101101
it("rewrites persisted assistant messages with empty content arrays", async () => {
102102
const { file } = await createTempSessionPath();
103-
const { header } = buildSessionHeaderAndMessage();
103+
const { header, message } = buildSessionHeaderAndMessage();
104104
const poisonedAssistantEntry = {
105105
type: "message",
106106
id: "msg-2",
@@ -117,7 +117,15 @@ describe("repairSessionFileIfNeeded", () => {
117117
errorMessage: "transient stream failure",
118118
},
119119
};
120-
const original = `${JSON.stringify(header)}\n${JSON.stringify(poisonedAssistantEntry)}\n`;
120+
// Follow-up so the session doesn't end on assistant (trailing-trim is tested separately).
121+
const followUp = {
122+
type: "message",
123+
id: "msg-3",
124+
parentId: null,
125+
timestamp: new Date().toISOString(),
126+
message: { role: "user", content: "retry" },
127+
};
128+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(poisonedAssistantEntry)}\n${JSON.stringify(followUp)}\n`;
121129
await fs.writeFile(file, original, "utf-8");
122130

123131
const warn = vi.fn();
@@ -127,25 +135,23 @@ describe("repairSessionFileIfNeeded", () => {
127135
expect(result.droppedLines).toBe(0);
128136
expect(result.rewrittenAssistantMessages).toBe(1);
129137
expect(result.backupPath).toBeTruthy();
130-
// Warn message must omit the "dropped 0 malformed line(s)" noise when
131-
// nothing was dropped; only the rewrite count is reported.
132138
expect(warn).toHaveBeenCalledTimes(1);
133139
const warnMessage = warn.mock.calls[0]?.[0] as string;
134140
expect(warnMessage).toContain("rewrote 1 assistant message(s)");
135141
expect(warnMessage).not.toContain("dropped");
136142

137143
const repaired = await fs.readFile(file, "utf-8");
138144
const repairedLines = repaired.trim().split("\n");
139-
expect(repairedLines).toHaveLength(2);
145+
expect(repairedLines).toHaveLength(4);
140146
const repairedEntry: { message: { content: { type: string; text: string }[] } } = JSON.parse(
141-
repairedLines[1],
147+
repairedLines[2],
142148
);
143149
expect(repairedEntry.message.content).toEqual([
144150
{ type: "text", text: "[assistant turn failed before producing content]" },
145151
]);
146152
});
147153

148-
it("drops persisted blank user text messages", async () => {
154+
it("rewrites blank-only user text messages to synthetic placeholder instead of dropping", async () => {
149155
const { file } = await createTempSessionPath();
150156
const { header, message } = buildSessionHeaderAndMessage();
151157
const blankUserEntry = {
@@ -165,13 +171,46 @@ describe("repairSessionFileIfNeeded", () => {
165171
const result = await repairSessionFileIfNeeded({ sessionFile: file, warn });
166172

167173
expect(result.repaired).toBe(true);
168-
expect(result.droppedBlankUserMessages).toBe(1);
169-
expect(warn.mock.calls[0]?.[0]).toContain("dropped 1 blank user message(s)");
174+
expect(result.rewrittenUserMessages).toBe(1);
175+
expect(result.droppedBlankUserMessages).toBe(0);
176+
expect(warn.mock.calls[0]?.[0]).toContain("rewrote 1 user message(s)");
170177

171178
const repaired = await fs.readFile(file, "utf-8");
172179
const repairedLines = repaired.trim().split("\n");
173-
expect(repairedLines).toHaveLength(2);
174-
expect(JSON.parse(repairedLines[1])?.id).toBe("msg-1");
180+
expect(repairedLines).toHaveLength(3);
181+
const rewrittenEntry = JSON.parse(repairedLines[1]);
182+
expect(rewrittenEntry.id).toBe("msg-blank");
183+
expect(rewrittenEntry.message.content).toEqual([
184+
{ type: "text", text: BLANK_USER_FALLBACK_TEXT },
185+
]);
186+
});
187+
188+
it("rewrites blank string-content user messages to placeholder", async () => {
189+
const { file } = await createTempSessionPath();
190+
const { header, message } = buildSessionHeaderAndMessage();
191+
const blankStringUserEntry = {
192+
type: "message",
193+
id: "msg-blank-str",
194+
parentId: null,
195+
timestamp: new Date().toISOString(),
196+
message: {
197+
role: "user",
198+
content: " ",
199+
},
200+
};
201+
const original = `${JSON.stringify(header)}\n${JSON.stringify(blankStringUserEntry)}\n${JSON.stringify(message)}\n`;
202+
await fs.writeFile(file, original, "utf-8");
203+
204+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
205+
206+
expect(result.repaired).toBe(true);
207+
expect(result.rewrittenUserMessages).toBe(1);
208+
209+
const repaired = await fs.readFile(file, "utf-8");
210+
const repairedLines = repaired.trim().split("\n");
211+
expect(repairedLines).toHaveLength(3);
212+
const rewrittenEntry = JSON.parse(repairedLines[1]);
213+
expect(rewrittenEntry.message.content).toBe(BLANK_USER_FALLBACK_TEXT);
175214
});
176215

177216
it("removes blank user text blocks while preserving media blocks", async () => {
@@ -237,12 +276,6 @@ describe("repairSessionFileIfNeeded", () => {
237276
});
238277

239278
it("does not rewrite silent-reply turns (stopReason=stop, content=[]) on disk", async () => {
240-
// Mirror of the in-memory replay-history test: a clean stop with no
241-
// content is a legitimate silent reply (NO_REPLY token path). Repair
242-
// must NOT permanently mutate it into a synthetic "[assistant turn
243-
// failed before producing content]" entry — that would corrupt the
244-
// historical transcript and replay fabricated failure text on every
245-
// future provider request.
246279
const { file } = await createTempSessionPath();
247280
const { header } = buildSessionHeaderAndMessage();
248281
const silentReplyEntry = {
@@ -260,7 +293,15 @@ describe("repairSessionFileIfNeeded", () => {
260293
stopReason: "stop",
261294
},
262295
};
263-
const original = `${JSON.stringify(header)}\n${JSON.stringify(silentReplyEntry)}\n`;
296+
// Follow-up so the session doesn't end on assistant (trailing-trim is tested separately).
297+
const followUp = {
298+
type: "message",
299+
id: "msg-3",
300+
parentId: null,
301+
timestamp: new Date().toISOString(),
302+
message: { role: "user", content: "follow up" },
303+
};
304+
const original = `${JSON.stringify(header)}\n${JSON.stringify(silentReplyEntry)}\n${JSON.stringify(followUp)}\n`;
264305
await fs.writeFile(file, original, "utf-8");
265306

266307
const result = await repairSessionFileIfNeeded({ sessionFile: file });
@@ -271,6 +312,198 @@ describe("repairSessionFileIfNeeded", () => {
271312
expect(after).toBe(original);
272313
});
273314

315+
it("trims trailing assistant messages from the session file", async () => {
316+
const { file } = await createTempSessionPath();
317+
const { header, message } = buildSessionHeaderAndMessage();
318+
const assistantEntry = {
319+
type: "message",
320+
id: "msg-asst",
321+
parentId: null,
322+
timestamp: new Date().toISOString(),
323+
message: {
324+
role: "assistant",
325+
content: [{ type: "text", text: "stale answer" }],
326+
stopReason: "stop",
327+
},
328+
};
329+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry)}\n`;
330+
await fs.writeFile(file, original, "utf-8");
331+
332+
const warn = vi.fn();
333+
const result = await repairSessionFileIfNeeded({ sessionFile: file, warn });
334+
335+
expect(result.repaired).toBe(true);
336+
expect(result.trimmedTrailingAssistantMessages).toBe(1);
337+
expect(warn.mock.calls[0]?.[0]).toContain("trimmed 1 trailing assistant message(s)");
338+
339+
const repaired = await fs.readFile(file, "utf-8");
340+
const repairedLines = repaired.trim().split("\n");
341+
expect(repairedLines).toHaveLength(2);
342+
});
343+
344+
it("trims multiple consecutive trailing assistant messages", async () => {
345+
const { file } = await createTempSessionPath();
346+
const { header, message } = buildSessionHeaderAndMessage();
347+
const assistantEntry1 = {
348+
type: "message",
349+
id: "msg-asst-1",
350+
parentId: null,
351+
timestamp: new Date().toISOString(),
352+
message: {
353+
role: "assistant",
354+
content: [{ type: "text", text: "first" }],
355+
stopReason: "stop",
356+
},
357+
};
358+
const assistantEntry2 = {
359+
type: "message",
360+
id: "msg-asst-2",
361+
parentId: null,
362+
timestamp: new Date().toISOString(),
363+
message: {
364+
role: "assistant",
365+
content: [{ type: "text", text: "second" }],
366+
stopReason: "stop",
367+
},
368+
};
369+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry1)}\n${JSON.stringify(assistantEntry2)}\n`;
370+
await fs.writeFile(file, original, "utf-8");
371+
372+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
373+
374+
expect(result.repaired).toBe(true);
375+
expect(result.trimmedTrailingAssistantMessages).toBe(2);
376+
377+
const repaired = await fs.readFile(file, "utf-8");
378+
const repairedLines = repaired.trim().split("\n");
379+
expect(repairedLines).toHaveLength(2);
380+
});
381+
382+
it("does not trim non-trailing assistant messages", async () => {
383+
const { file } = await createTempSessionPath();
384+
const { header, message } = buildSessionHeaderAndMessage();
385+
const assistantEntry = {
386+
type: "message",
387+
id: "msg-asst",
388+
parentId: null,
389+
timestamp: new Date().toISOString(),
390+
message: {
391+
role: "assistant",
392+
content: [{ type: "text", text: "answer" }],
393+
stopReason: "stop",
394+
},
395+
};
396+
const userFollowUp = {
397+
type: "message",
398+
id: "msg-user-2",
399+
parentId: null,
400+
timestamp: new Date().toISOString(),
401+
message: { role: "user", content: "follow up" },
402+
};
403+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry)}\n${JSON.stringify(userFollowUp)}\n`;
404+
await fs.writeFile(file, original, "utf-8");
405+
406+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
407+
408+
expect(result.repaired).toBe(false);
409+
expect(result.trimmedTrailingAssistantMessages ?? 0).toBe(0);
410+
});
411+
412+
it("preserves trailing assistant messages that contain tool calls", async () => {
413+
const { file } = await createTempSessionPath();
414+
const { header, message } = buildSessionHeaderAndMessage();
415+
const toolCallAssistant = {
416+
type: "message",
417+
id: "msg-asst-tc",
418+
parentId: null,
419+
timestamp: new Date().toISOString(),
420+
message: {
421+
role: "assistant",
422+
content: [
423+
{ type: "text", text: "Let me check that." },
424+
{ type: "toolCall", id: "call_1", name: "read", input: { path: "/tmp/test" } },
425+
],
426+
stopReason: "toolUse",
427+
},
428+
};
429+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n`;
430+
await fs.writeFile(file, original, "utf-8");
431+
432+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
433+
434+
expect(result.repaired).toBe(false);
435+
expect(result.trimmedTrailingAssistantMessages ?? 0).toBe(0);
436+
const after = await fs.readFile(file, "utf-8");
437+
expect(after).toBe(original);
438+
});
439+
440+
it("trims non-tool-call assistant but stops at tool-call assistant", async () => {
441+
const { file } = await createTempSessionPath();
442+
const { header, message } = buildSessionHeaderAndMessage();
443+
const toolCallAssistant = {
444+
type: "message",
445+
id: "msg-asst-tc",
446+
parentId: null,
447+
timestamp: new Date().toISOString(),
448+
message: {
449+
role: "assistant",
450+
content: [{ type: "toolUse", id: "call_1", name: "read" }],
451+
stopReason: "toolUse",
452+
},
453+
};
454+
const plainAssistant = {
455+
type: "message",
456+
id: "msg-asst-plain",
457+
parentId: null,
458+
timestamp: new Date().toISOString(),
459+
message: {
460+
role: "assistant",
461+
content: [{ type: "text", text: "stale" }],
462+
stopReason: "stop",
463+
},
464+
};
465+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(plainAssistant)}\n`;
466+
await fs.writeFile(file, original, "utf-8");
467+
468+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
469+
470+
expect(result.repaired).toBe(true);
471+
expect(result.trimmedTrailingAssistantMessages).toBe(1);
472+
473+
const repaired = await fs.readFile(file, "utf-8");
474+
const repairedLines = repaired.trim().split("\n");
475+
expect(repairedLines).toHaveLength(3);
476+
expect(JSON.parse(repairedLines[2]).id).toBe("msg-asst-tc");
477+
});
478+
479+
it("never trims below the session header", async () => {
480+
const { file } = await createTempSessionPath();
481+
const { header } = buildSessionHeaderAndMessage();
482+
const assistantEntry = {
483+
type: "message",
484+
id: "msg-asst",
485+
parentId: null,
486+
timestamp: new Date().toISOString(),
487+
message: {
488+
role: "assistant",
489+
content: [{ type: "text", text: "orphan" }],
490+
stopReason: "stop",
491+
},
492+
};
493+
const original = `${JSON.stringify(header)}\n${JSON.stringify(assistantEntry)}\n`;
494+
await fs.writeFile(file, original, "utf-8");
495+
496+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
497+
498+
expect(result.repaired).toBe(true);
499+
expect(result.trimmedTrailingAssistantMessages).toBe(1);
500+
501+
const repaired = await fs.readFile(file, "utf-8");
502+
const repairedLines = repaired.trim().split("\n");
503+
expect(repairedLines).toHaveLength(1);
504+
expect(JSON.parse(repairedLines[0]).type).toBe("session");
505+
});
506+
274507
it("is a no-op on a session that was already repaired", async () => {
275508
const { file } = await createTempSessionPath();
276509
const { header } = buildSessionHeaderAndMessage();
@@ -289,7 +522,15 @@ describe("repairSessionFileIfNeeded", () => {
289522
stopReason: "error",
290523
},
291524
};
292-
const original = `${JSON.stringify(header)}\n${JSON.stringify(healedEntry)}\n`;
525+
// Follow-up so the session doesn't end on assistant (trailing-trim is tested separately).
526+
const followUp = {
527+
type: "message",
528+
id: "msg-3",
529+
parentId: null,
530+
timestamp: new Date().toISOString(),
531+
message: { role: "user", content: "follow up" },
532+
};
533+
const original = `${JSON.stringify(header)}\n${JSON.stringify(healedEntry)}\n${JSON.stringify(followUp)}\n`;
293534
await fs.writeFile(file, original, "utf-8");
294535

295536
const result = await repairSessionFileIfNeeded({ sessionFile: file });

0 commit comments

Comments
 (0)