@@ -72,6 +72,14 @@ function textMessage(id: string, text = id) {
7272 return { id, role : "assistant" as const , parts : [ { type : "text" , text } ] } ;
7373}
7474
75+ function toolMessage ( id : string , state : "input-available" | "output-available" ) {
76+ return {
77+ id,
78+ role : "assistant" as const ,
79+ parts : [ { type : "tool-get_query_schema" , state, toolCallId : `${ id } _call` , input : { } } ] ,
80+ } ;
81+ }
82+
7583async function transcript ( chatId : string ) : Promise < { id : string } [ ] > {
7684 return ( await getChatMessages ( agentDb , {
7785 chatId,
@@ -289,21 +297,31 @@ describe("invariant 3: an ordinary transcript write can never change a stored me
289297 ) ;
290298
291299 postgresTest (
292- "persistTurn cannot rewrite a stored message either " ,
300+ "a completing turn finalises the body it stored mid-flight " ,
293301 async ( { prisma, postgresContainer } ) => {
294- const chatId = "chat_no_implicit_update_turn" ;
302+ // `onTurnStart` stores the turn's messages before the model has finished, so the
303+ // transcript first holds a tool call with no result. The completed turn arrives
304+ // under the same message id, and what the user was shown has to win.
305+ const chatId = "chat_turn_finalises_own_message" ;
295306 await boot ( prisma , postgresContainer . getConnectionUri ( ) , chatId ) ;
296307
297- await persistMessages ( agentDb , { chatId, messages : [ textMessage ( "a1" , "the answer " ) ] } ) ;
308+ await persistMessages ( agentDb , { chatId, messages : [ toolMessage ( "a1" , "input-available " ) ] } ) ;
298309 const before = await rows ( prisma , chatId ) ;
299310
300311 await persistTurn ( agentDb , {
301312 chatId,
302- messages : [ textMessage ( "a1" , "a different answer " ) ] ,
313+ messages : [ toolMessage ( "a1" , "output-available " ) ] ,
303314 session : { publicAccessToken : "pat_store" } ,
304315 } ) ;
305316
306- expect ( await rows ( prisma , chatId ) ) . toEqual ( before ) ;
317+ const after = await rows ( prisma , chatId ) ;
318+ expect ( after ) . toHaveLength ( 1 ) ;
319+ expect ( after [ 0 ] ! . position ) . toBe ( before [ 0 ] ! . position ) ;
320+ expect ( after [ 0 ] ! . message ) . toMatchObject ( {
321+ parts : [ { state : "output-available" } ] ,
322+ } ) ;
323+ // A finalisation is not an append: no slot is consumed.
324+ expect ( await nextPosition ( prisma , chatId ) ) . toBe ( 2 ) ;
307325 } ,
308326 30_000
309327 ) ;
@@ -620,6 +638,19 @@ describe("a write can no longer lose a message another process appended", () =>
620638 // And the row it belongs to is still terminal, so nothing will re-open it.
621639 const row = await getInvestigation ( agentDb , { id : created . id } ) ;
622640 expect ( investigationStateSchema . parse ( row ?. state ) . outcome ) . toBe ( "inconclusive" ) ;
641+
642+ // A later turn carrying the card in its own snapshot still can't rewrite it:
643+ // finalisation is for the turn's messages, never for a durable event.
644+ const card = ( await rows ( prisma , chatId ) ) . find ( ( stored ) => stored . message_id === cardId ) ! ;
645+ await persistTurn ( agentDb , {
646+ chatId,
647+ messages : [ { ...( card . message as Record < string , unknown > ) , tampered : true } ] ,
648+ session : { publicAccessToken : "pat_store" } ,
649+ } ) ;
650+ const afterCard = ( await rows ( prisma , chatId ) ) . find (
651+ ( stored ) => stored . message_id === cardId
652+ ) ! ;
653+ expect ( afterCard . message ) . toEqual ( card . message ) ;
623654 } ,
624655 30_000
625656 ) ;
0 commit comments