@@ -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" ;
66
77function 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