Skip to content

Commit 07e3179

Browse files
committed
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.
1 parent ca620ea commit 07e3179

3 files changed

Lines changed: 281 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
3434
- 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.
3535
- 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.
3636
- Docker: restore `python3` in the gateway runtime image after the slim-runtime switch. Fixes #75041.
37+
- Agents/session-repair: trim trailing assistant messages from session JSONL files so sessions ending on `role=assistant` no longer trigger Anthropic 400 prefill-loop rejections across restarts, and rewrite 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. Fixes #75271 and #75313. Thanks @amknight.
3738
- 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.
3839
- 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.
3940
- 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: 205 additions & 12 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,16 @@ describe("repairSessionFileIfNeeded", () => {
117117
errorMessage: "transient stream failure",
118118
},
119119
};
120-
const original = `${JSON.stringify(header)}\n${JSON.stringify(poisonedAssistantEntry)}\n`;
120+
// Include a user follow-up after the poisoned assistant so the session
121+
// doesn’t end on assistant (which would also trigger trailing-trim).
122+
const followUp = {
123+
type: "message",
124+
id: "msg-3",
125+
parentId: null,
126+
timestamp: new Date().toISOString(),
127+
message: { role: "user", content: "retry" },
128+
};
129+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(poisonedAssistantEntry)}\n${JSON.stringify(followUp)}\n`;
121130
await fs.writeFile(file, original, "utf-8");
122131

123132
const warn = vi.fn();
@@ -136,16 +145,17 @@ describe("repairSessionFileIfNeeded", () => {
136145

137146
const repaired = await fs.readFile(file, "utf-8");
138147
const repairedLines = repaired.trim().split("\n");
139-
expect(repairedLines).toHaveLength(2);
148+
// header + user + rewritten-assistant + follow-up user
149+
expect(repairedLines).toHaveLength(4);
140150
const repairedEntry: { message: { content: { type: string; text: string }[] } } = JSON.parse(
141-
repairedLines[1],
151+
repairedLines[2],
142152
);
143153
expect(repairedEntry.message.content).toEqual([
144154
{ type: "text", text: "[assistant turn failed before producing content]" },
145155
]);
146156
});
147157

148-
it("drops persisted blank user text messages", async () => {
158+
it("rewrites blank-only user text messages to synthetic placeholder instead of dropping", async () => {
149159
const { file } = await createTempSessionPath();
150160
const { header, message } = buildSessionHeaderAndMessage();
151161
const blankUserEntry = {
@@ -165,13 +175,48 @@ describe("repairSessionFileIfNeeded", () => {
165175
const result = await repairSessionFileIfNeeded({ sessionFile: file, warn });
166176

167177
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)");
178+
// Blank user messages are now rewritten, not dropped.
179+
expect(result.rewrittenUserMessages).toBe(1);
180+
expect(result.droppedBlankUserMessages).toBe(0);
181+
expect(warn.mock.calls[0]?.[0]).toContain("rewrote 1 user message(s)");
170182

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

177222
it("removes blank user text blocks while preserving media blocks", async () => {
@@ -260,7 +305,16 @@ describe("repairSessionFileIfNeeded", () => {
260305
stopReason: "stop",
261306
},
262307
};
263-
const original = `${JSON.stringify(header)}\n${JSON.stringify(silentReplyEntry)}\n`;
308+
// Append a user follow-up so the session doesn't end on assistant —
309+
// this test is about per-entry rewrite behavior, not trailing-trim.
310+
const followUp = {
311+
type: "message",
312+
id: "msg-3",
313+
parentId: null,
314+
timestamp: new Date().toISOString(),
315+
message: { role: "user", content: "follow up" },
316+
};
317+
const original = `${JSON.stringify(header)}\n${JSON.stringify(silentReplyEntry)}\n${JSON.stringify(followUp)}\n`;
264318
await fs.writeFile(file, original, "utf-8");
265319

266320
const result = await repairSessionFileIfNeeded({ sessionFile: file });
@@ -271,6 +325,136 @@ describe("repairSessionFileIfNeeded", () => {
271325
expect(after).toBe(original);
272326
});
273327

328+
it("trims trailing assistant messages from the session file", async () => {
329+
const { file } = await createTempSessionPath();
330+
const { header, message } = buildSessionHeaderAndMessage();
331+
const assistantEntry = {
332+
type: "message",
333+
id: "msg-asst",
334+
parentId: null,
335+
timestamp: new Date().toISOString(),
336+
message: {
337+
role: "assistant",
338+
content: [{ type: "text", text: "stale answer" }],
339+
stopReason: "stop",
340+
},
341+
};
342+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry)}\n`;
343+
await fs.writeFile(file, original, "utf-8");
344+
345+
const warn = vi.fn();
346+
const result = await repairSessionFileIfNeeded({ sessionFile: file, warn });
347+
348+
expect(result.repaired).toBe(true);
349+
expect(result.trimmedTrailingAssistantMessages).toBe(1);
350+
expect(warn.mock.calls[0]?.[0]).toContain("trimmed 1 trailing assistant message(s)");
351+
352+
const repaired = await fs.readFile(file, "utf-8");
353+
const repairedLines = repaired.trim().split("\n");
354+
// Only header + user message remain; assistant was trimmed.
355+
expect(repairedLines).toHaveLength(2);
356+
});
357+
358+
it("trims multiple consecutive trailing assistant messages", async () => {
359+
const { file } = await createTempSessionPath();
360+
const { header, message } = buildSessionHeaderAndMessage();
361+
const assistantEntry1 = {
362+
type: "message",
363+
id: "msg-asst-1",
364+
parentId: null,
365+
timestamp: new Date().toISOString(),
366+
message: {
367+
role: "assistant",
368+
content: [{ type: "text", text: "first" }],
369+
stopReason: "stop",
370+
},
371+
};
372+
const assistantEntry2 = {
373+
type: "message",
374+
id: "msg-asst-2",
375+
parentId: null,
376+
timestamp: new Date().toISOString(),
377+
message: {
378+
role: "assistant",
379+
content: [{ type: "text", text: "second" }],
380+
stopReason: "stop",
381+
},
382+
};
383+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry1)}\n${JSON.stringify(assistantEntry2)}\n`;
384+
await fs.writeFile(file, original, "utf-8");
385+
386+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
387+
388+
expect(result.repaired).toBe(true);
389+
expect(result.trimmedTrailingAssistantMessages).toBe(2);
390+
391+
const repaired = await fs.readFile(file, "utf-8");
392+
const repairedLines = repaired.trim().split("\n");
393+
expect(repairedLines).toHaveLength(2);
394+
});
395+
396+
it("does not trim non-trailing assistant messages", async () => {
397+
const { file } = await createTempSessionPath();
398+
const { header, message } = buildSessionHeaderAndMessage();
399+
const assistantEntry = {
400+
type: "message",
401+
id: "msg-asst",
402+
parentId: null,
403+
timestamp: new Date().toISOString(),
404+
message: {
405+
role: "assistant",
406+
content: [{ type: "text", text: "answer" }],
407+
stopReason: "stop",
408+
},
409+
};
410+
const userFollowUp = {
411+
type: "message",
412+
id: "msg-user-2",
413+
parentId: null,
414+
timestamp: new Date().toISOString(),
415+
message: { role: "user", content: "follow up" },
416+
};
417+
// Ends on user, so nothing to trim.
418+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry)}\n${JSON.stringify(userFollowUp)}\n`;
419+
await fs.writeFile(file, original, "utf-8");
420+
421+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
422+
423+
expect(result.repaired).toBe(false);
424+
expect(result.trimmedTrailingAssistantMessages ?? 0).toBe(0);
425+
});
426+
427+
it("never trims below the session header", async () => {
428+
// Edge case: a session with only a header and trailing assistant messages
429+
// should keep the header intact.
430+
const { file } = await createTempSessionPath();
431+
const { header } = buildSessionHeaderAndMessage();
432+
const assistantEntry = {
433+
type: "message",
434+
id: "msg-asst",
435+
parentId: null,
436+
timestamp: new Date().toISOString(),
437+
message: {
438+
role: "assistant",
439+
content: [{ type: "text", text: "orphan" }],
440+
stopReason: "stop",
441+
},
442+
};
443+
const original = `${JSON.stringify(header)}\n${JSON.stringify(assistantEntry)}\n`;
444+
await fs.writeFile(file, original, "utf-8");
445+
446+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
447+
448+
expect(result.repaired).toBe(true);
449+
expect(result.trimmedTrailingAssistantMessages).toBe(1);
450+
451+
const repaired = await fs.readFile(file, "utf-8");
452+
const repairedLines = repaired.trim().split("\n");
453+
// Only the header remains.
454+
expect(repairedLines).toHaveLength(1);
455+
expect(JSON.parse(repairedLines[0]).type).toBe("session");
456+
});
457+
274458
it("is a no-op on a session that was already repaired", async () => {
275459
const { file } = await createTempSessionPath();
276460
const { header } = buildSessionHeaderAndMessage();
@@ -289,7 +473,16 @@ describe("repairSessionFileIfNeeded", () => {
289473
stopReason: "error",
290474
},
291475
};
292-
const original = `${JSON.stringify(header)}\n${JSON.stringify(healedEntry)}\n`;
476+
// Append a user follow-up so the session doesn't end on assistant —
477+
// this test is about idempotent per-entry repair, not trailing-trim.
478+
const followUp = {
479+
type: "message",
480+
id: "msg-3",
481+
parentId: null,
482+
timestamp: new Date().toISOString(),
483+
message: { role: "user", content: "follow up" },
484+
};
485+
const original = `${JSON.stringify(header)}\n${JSON.stringify(healedEntry)}\n${JSON.stringify(followUp)}\n`;
293486
await fs.writeFile(file, original, "utf-8");
294487

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

0 commit comments

Comments
 (0)