@@ -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,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