




















@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
22import os from "node:os";
33import path from "node:path";
44import { 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";
6677function buildSessionHeaderAndMessage() {
88const header = {
@@ -100,7 +100,7 @@ describe("repairSessionFileIfNeeded", () => {
100100101101it("rewrites persisted assistant messages with empty content arrays", async () => {
102102const { file } = await createTempSessionPath();
103-const { header } = buildSessionHeaderAndMessage();
103+const { header, message } = buildSessionHeaderAndMessage();
104104const poisonedAssistantEntry = {
105105type: "message",
106106id: "msg-2",
@@ -117,7 +117,15 @@ describe("repairSessionFileIfNeeded", () => {
117117errorMessage: "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`;
121129await fs.writeFile(file, original, "utf-8");
122130123131const warn = vi.fn();
@@ -127,25 +135,23 @@ describe("repairSessionFileIfNeeded", () => {
127135expect(result.droppedLines).toBe(0);
128136expect(result.rewrittenAssistantMessages).toBe(1);
129137expect(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.
132138expect(warn).toHaveBeenCalledTimes(1);
133139const warnMessage = warn.mock.calls[0]?.[0] as string;
134140expect(warnMessage).toContain("rewrote 1 assistant message(s)");
135141expect(warnMessage).not.toContain("dropped");
136142137143const repaired = await fs.readFile(file, "utf-8");
138144const repairedLines = repaired.trim().split("\n");
139-expect(repairedLines).toHaveLength(2);
145+expect(repairedLines).toHaveLength(4);
140146const repairedEntry: { message: { content: { type: string; text: string }[] } } = JSON.parse(
141-repairedLines[1],
147+repairedLines[2],
142148);
143149expect(repairedEntry.message.content).toEqual([
144150{ type: "text", text: "[assistant turn failed before producing content]" },
145151]);
146152});
147153148-it("drops persisted blank user text messages", async () => {
154+it("rewrites blank-only user text messages to synthetic placeholder instead of dropping", async () => {
149155const { file } = await createTempSessionPath();
150156const { header, message } = buildSessionHeaderAndMessage();
151157const blankUserEntry = {
@@ -165,13 +171,46 @@ describe("repairSessionFileIfNeeded", () => {
165171const result = await repairSessionFileIfNeeded({ sessionFile: file, warn });
166172167173expect(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)");
170177171178const repaired = await fs.readFile(file, "utf-8");
172179const 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});
176215177216it("removes blank user text blocks while preserving media blocks", async () => {
@@ -237,12 +276,6 @@ describe("repairSessionFileIfNeeded", () => {
237276});
238277239278it("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.
246279const { file } = await createTempSessionPath();
247280const { header } = buildSessionHeaderAndMessage();
248281const silentReplyEntry = {
@@ -260,7 +293,15 @@ describe("repairSessionFileIfNeeded", () => {
260293stopReason: "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`;
264305await fs.writeFile(file, original, "utf-8");
265306266307const result = await repairSessionFileIfNeeded({ sessionFile: file });
@@ -271,6 +312,198 @@ describe("repairSessionFileIfNeeded", () => {
271312expect(after).toBe(original);
272313});
273314315+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+274507it("is a no-op on a session that was already repaired", async () => {
275508const { file } = await createTempSessionPath();
276509const { header } = buildSessionHeaderAndMessage();
@@ -289,7 +522,15 @@ describe("repairSessionFileIfNeeded", () => {
289522stopReason: "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`;
293534await fs.writeFile(file, original, "utf-8");
294535295536const result = await repairSessionFileIfNeeded({ sessionFile: file });
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。