@@ -227,49 +227,66 @@ describe("the session cursor a refreshed client resumes from", () => {
227227
228228describe ( "a failed snapshot write leaves the next boot a clean replay" , ( ) => {
229229 postgresTest (
230- "a persistTurn that throws commits nothing , and the retry replays with no loss" ,
230+ "a persistTurn that throws mid-write rolls back what it already wrote , and the retry replays with no loss" ,
231231 async ( { prisma, postgresContainer } ) => {
232232 const chatId = "chat_write_fail" ;
233233 await boot ( prisma , postgresContainer . getConnectionUri ( ) , chatId ) ;
234234
235- // A durable first turn, and the session cursor it left.
235+ // A durable first turn, its tool call still mid-flight, and the session cursor it left.
236236 await persistTurn ( agentDb , {
237237 chatId,
238- messages : [ textMessage ( "u1" , "user" ) , textMessage ( "a1" ) ] ,
238+ messages : [ textMessage ( "u1" , "user" ) , toolMessage ( "a1" , "input-available ") ] ,
239239 session : { publicAccessToken : "pat1" , lastEventId : "1" , runId : "run1" } ,
240240 } ) ;
241241 const positionBefore = await nextPosition ( prisma , chatId ) ;
242242
243- // The next turn's write fails partway — a malformed message with no id throws inside the
244- // transaction, after the (would-be) settlement/message work has begun.
243+ // The next turn's write fails *after* it has written: a message that carries an id but no
244+ // role clears the up-front id check, so the store finalises `a1` in place and reserves the
245+ // slots for the new messages before the missing role throws. Everything already written
246+ // has to come back out.
245247 await expect (
246248 persistTurn ( agentDb , {
247249 chatId,
248250 messages : [
249251 textMessage ( "u1" , "user" ) ,
250- textMessage ( "a1" ) ,
252+ toolMessage ( "a1" , "output-available ") ,
251253 textMessage ( "a2" ) ,
252- { role : "assistant " , parts : [ ] } as unknown as { id : string ; role : string } ,
254+ { id : "a3 " , parts : [ ] } as unknown as { id : string ; role : string } ,
253255 ] ,
256+ finalizeMessageIds : [ "a1" ] ,
254257 session : { publicAccessToken : "pat_torn" , lastEventId : "2" , runId : "run_torn" } ,
255258 } )
256- ) . rejects . toThrow ( / h a n d e d a m e s s a g e w i t h n o i d / ) ;
259+ ) . rejects . toThrow ( / h a n d e d a m e s s a g e w i t h n o r o l e / ) ;
257260
258- // The whole turn rolled back: no new rows, allocator untouched, and — the version-
259- // mismatch case — the session cursor is still the first turn's, not the torn one's .
261+ // The whole turn rolled back. The in-place rewrite the store had already applied is undone:
262+ // `a1` is the mid-flight call again, not the finalised body the torn turn wrote .
260263 expect ( ( await transcript ( chatId ) ) . map ( ( m ) => m . id ) ) . toEqual ( [ "u1" , "a1" ] ) ;
264+ const tornA1 = ( await transcript ( chatId ) ) [ 1 ] as unknown as { parts : { state : string } [ ] } ;
265+ expect ( tornA1 . parts [ 0 ] ! . state ) . toBe ( "input-available" ) ;
266+ expect ( await rowCount ( prisma , chatId ) ) . toBe ( 2 ) ;
267+ // The slots it reserved for `a2`/`a3` came back too, so the retry doesn't leave a gap.
261268 expect ( await nextPosition ( prisma , chatId ) ) . toBe ( positionBefore ) ;
269+ // The cursor is still the first turn's: the failed turn never got as far as writing one.
262270 expect (
263271 await getSession ( agentDb , { chatId, organizationId : ORG , userId : USER } )
264272 ) . toMatchObject ( { publicAccessToken : "pat1" , lastEventId : "1" } ) ;
265273
266274 // The retry — a clean replay of the same turn — lands everything exactly once.
267275 await persistTurn ( agentDb , {
268276 chatId,
269- messages : [ textMessage ( "u1" , "user" ) , textMessage ( "a1" ) , textMessage ( "a2" ) ] ,
277+ messages : [
278+ textMessage ( "u1" , "user" ) ,
279+ toolMessage ( "a1" , "output-available" ) ,
280+ textMessage ( "a2" ) ,
281+ ] ,
282+ finalizeMessageIds : [ "a1" ] ,
270283 session : { publicAccessToken : "pat2" , lastEventId : "2" , runId : "run2" } ,
271284 } ) ;
272285 expect ( ( await transcript ( chatId ) ) . map ( ( m ) => m . id ) ) . toEqual ( [ "u1" , "a1" , "a2" ] ) ;
286+ const retriedA1 = ( await transcript ( chatId ) ) [ 1 ] as unknown as { parts : { state : string } [ ] } ;
287+ expect ( retriedA1 . parts [ 0 ] ! . state ) . toBe ( "output-available" ) ;
288+ // One new row, one new slot: the rolled-back reservation was not double-counted.
289+ expect ( await nextPosition ( prisma , chatId ) ) . toBe ( positionBefore + 1 ) ;
273290 expect (
274291 await getSession ( agentDb , { chatId, organizationId : ORG , userId : USER } )
275292 ) . toMatchObject ( { publicAccessToken : "pat2" , lastEventId : "2" } ) ;
0 commit comments