@@ -26,6 +26,44 @@ import {
2626 USAGE ,
2727} from "./test-support" ;
2828
29+ /**
30+ * Stands in for `chat_messages`: the append is keyed on (chat_id, message_id) and does
31+ * nothing on conflict, so a repeated append is never a second row. Row counts are what
32+ * the History panel reads, so the retry tests assert those rather than call counts.
33+ */
34+ function transcriptTable ( ) {
35+ const rows : { chatId : string ; messageId : string } [ ] = [ ] ;
36+ const countOf = ( chatId : string , messageId : string ) =>
37+ rows . filter ( ( row ) => row . chatId === chatId && row . messageId === messageId ) . length ;
38+ return {
39+ countOf,
40+ insert ( chatId : string , message : UIMessage ) {
41+ if ( countOf ( chatId , message . id ) > 0 ) return false ;
42+ rows . push ( { chatId, messageId : message . id } ) ;
43+ return true ;
44+ } ,
45+ } ;
46+ }
47+
48+ // A store that writes into `table`, failing the appends `failWhen` selects.
49+ function appendingStore (
50+ table : ReturnType < typeof transcriptTable > ,
51+ failWhen : ( message : UIMessage ) => boolean ,
52+ options ?: Parameters < typeof fakeStore > [ 0 ]
53+ ) {
54+ const { store, calls } = fakeStore ( options ) ;
55+ const wrapped : DashboardAgentStore = {
56+ ...store ,
57+ appendMessage : async ( args ) => {
58+ await store . appendMessage ( args ) ;
59+ const message = args . message as UIMessage ;
60+ if ( failWhen ( message ) ) throw new Error ( "the append lost the connection" ) ;
61+ return table . insert ( args . chatId , message ) ;
62+ } ,
63+ } ;
64+ return { store : wrapped , calls } ;
65+ }
66+
2967describe ( "watch wake narration" , ( ) => {
3068 let harness : MockChatAgentHarness | undefined ;
3169
@@ -84,10 +122,14 @@ describe("watch wake narration", () => {
84122 expect ( appended . userId ) . toBe ( CLIENT_DATA . userId ) ;
85123 expect ( appended . message ) . toMatchObject ( { id : "wake:watch:watch_1:fired" , role : "assistant" } ) ;
86124
87- // Same action id again (the watcher retried after appending): deduped.
125+ // Same action id again (the watcher retried after appending): nothing is narrated,
126+ // and the only write is the id-deduped repair of the same message.
88127 const second = await harness . sendAction ( WAKE ) ;
89128 expect ( collectText ( second . chunks ) ) . toBe ( "" ) ;
90- expect ( calls . appendMessage ) . toHaveLength ( 1 ) ;
129+ expect ( calls . appendMessage . map ( ( call ) => ( call as { message : UIMessage } ) . message . id ) ) . toEqual ( [
130+ "wake:watch:watch_1:fired" ,
131+ "wake:watch:watch_1:fired" ,
132+ ] ) ;
91133 } ) ;
92134
93135 // Records the prompt it was asked with, so the wake's framing can be asserted.
@@ -377,6 +419,62 @@ describe("watch wake narration", () => {
377419 "wake:watch:watch_2:expired" ,
378420 ] ) ;
379421 } ) ;
422+
423+ /**
424+ * The wake is durable on `session.out` the moment it streams, which is before the
425+ * display copy is written. So an append that fails leaves the model seeing a message
426+ * the History panel doesn't have — and the retry boots with that message already in
427+ * its history. Converging on the row is the retry's job.
428+ */
429+ it ( "appends the display copy on a retry that finds the wake already narrated" , async ( ) => {
430+ const table = transcriptTable ( ) ;
431+ const chatId = "chat_wake_retry" ;
432+ const wakeId = "wake:watch:watch_1:fired" ;
433+
434+ const failing = appendingStore ( table , ( message ) => message . id === wakeId ) ;
435+ harness = mockChatAgent ( dashboardAgent , {
436+ chatId,
437+ clientData : CLIENT_DATA ,
438+ setupLocals : ( { set } ) => {
439+ set ( dashboardAgentStoreKey , failing . store ) ;
440+ set ( dashboardAgentModelKey , mockModel ( [ textStep ( "never asked for" ) ] ) ) ;
441+ } ,
442+ } ) ;
443+
444+ const first = await harness . sendAction ( WAKE ) ;
445+ // Streamed — so it is on `session.out` and in the next boot's history — while the
446+ // row it was supposed to land alongside never arrived.
447+ expect ( collectText ( first . chunks ) ) . toContain ( "queue drained" ) ;
448+ expect ( failing . calls . appendMessage ) . toHaveLength ( 1 ) ;
449+ expect ( table . countOf ( chatId , wakeId ) ) . toBe ( 0 ) ;
450+ const durable = first . chunks ;
451+ await harness . close ( ) ;
452+
453+ // The retry is a new run picking up the session, booting its history from the
454+ // chunks the failed one left on `session.out`.
455+ const repairing = appendingStore ( table , ( ) => false ) ;
456+ harness = mockChatAgent ( dashboardAgent , {
457+ chatId,
458+ clientData : CLIENT_DATA ,
459+ continuation : true ,
460+ previousRunId : "run_wake_failed" ,
461+ setupLocals : ( { set } ) => {
462+ set ( dashboardAgentStoreKey , repairing . store ) ;
463+ set ( dashboardAgentModelKey , mockModel ( [ textStep ( "never asked for" ) ] ) ) ;
464+ } ,
465+ } ) ;
466+ harness . seedSessionOutTail ( durable ) ;
467+
468+ const retry = await harness . sendAction ( WAKE ) ;
469+
470+ // Nothing narrated twice, and the display copy converged on exactly one row.
471+ expect ( collectText ( retry . chunks ) ) . toBe ( "" ) ;
472+ expect ( table . countOf ( chatId , wakeId ) ) . toBe ( 1 ) ;
473+
474+ // A third delivery repairs nothing, because there is nothing left to repair.
475+ await harness . sendAction ( WAKE ) ;
476+ expect ( table . countOf ( chatId , wakeId ) ) . toBe ( 1 ) ;
477+ } ) ;
380478} ) ;
381479
382480describe ( "watch investigation" , ( ) => {
@@ -529,9 +627,13 @@ describe("watch investigation", () => {
529627 expect ( appended . message . id ) . toBe ( "investigate:watch:watch_1:fired:investigate" ) ;
530628 expect ( appended . message . parts . some ( ( part ) => part . type === "tool-render_view" ) ) . toBe ( true ) ;
531629
532- // The same kick again: nothing runs, nothing is written.
630+ // The same kick again: nothing runs, and the only write is the id-deduped repair of
631+ // the findings message.
533632 await harness . sendAction ( INVESTIGATE ) ;
534- expect ( calls . appendMessage ) . toHaveLength ( 1 ) ;
633+ expect ( calls . appendMessage . map ( ( call ) => ( call as { message : UIMessage } ) . message . id ) ) . toEqual ( [
634+ "investigate:watch:watch_1:fired:investigate" ,
635+ "investigate:watch:watch_1:fired:investigate" ,
636+ ] ) ;
535637 expect ( calls . upsertInvestigationRevision ) . toHaveLength ( 1 ) ;
536638 } ) ;
537639
@@ -724,7 +826,10 @@ describe("watch investigation", () => {
724826
725827 const revisions = calls . upsertInvestigationRevision . length ;
726828 await harness . sendAction ( INVESTIGATE ) ;
727- expect ( calls . appendMessage ) . toHaveLength ( 1 ) ;
829+ expect ( calls . appendMessage . map ( ( call ) => ( call as { message : UIMessage } ) . message . id ) ) . toEqual ( [
830+ "investigate:watch:watch_1:fired:investigate" ,
831+ "investigate:watch:watch_1:fired:investigate" ,
832+ ] ) ;
728833 expect ( calls . upsertInvestigationRevision ) . toHaveLength ( revisions ) ;
729834 } ) ;
730835
@@ -787,4 +892,65 @@ describe("watch investigation", () => {
787892 expect ( calls . upsertInvestigationRevision ) . toHaveLength ( 0 ) ;
788893 expect ( calls . appendMessage ) . toHaveLength ( 0 ) ;
789894 } ) ;
895+
896+ /**
897+ * The same window the wake has: the findings stream to `session.out` before the display
898+ * copy is appended, so an append that fails leaves the model holding a message the
899+ * History panel lost. The retry finds it already answered and must still land the row.
900+ */
901+ it ( "appends the display copy on a retry that finds the investigation already answered" , async ( ) => {
902+ const table = transcriptTable ( ) ;
903+ const chatId = "chat_investigate_retry" ;
904+ const findingsId = "investigate:watch:watch_1:fired:investigate" ;
905+ const seeded = { id : "inv_seeded" , projectRef : "proj_abc" , environmentRef : "env_abc" } ;
906+ const steps = [
907+ renderStep ( concluded , "inv_seeded" , "tc_verdict" ) ,
908+ textStep ( "The payload lost order.total." ) ,
909+ ] ;
910+
911+ const failing = appendingStore ( table , ( message ) => message . id === findingsId , {
912+ openInvestigation : seeded ,
913+ } ) ;
914+ harness = mockChatAgent ( dashboardAgent , {
915+ chatId,
916+ clientData : CLIENT_DATA_WITH_TOKEN ,
917+ setupLocals : ( { set } ) => {
918+ set ( dashboardAgentStoreKey , failing . store ) ;
919+ set ( dashboardAgentModelKey , recordingModel ( steps ) . model ) ;
920+ } ,
921+ } ) ;
922+
923+ const first = await harness . sendAction ( INVESTIGATE ) ;
924+ // Streamed whole, card part and all, while the row it belongs to never landed.
925+ expect ( executedTool ( first . chunks ) ) . toBe ( true ) ;
926+ expect ( failing . calls . appendMessage ) . toHaveLength ( 1 ) ;
927+ expect ( table . countOf ( chatId , findingsId ) ) . toBe ( 0 ) ;
928+ const durable = first . chunks ;
929+ await harness . close ( ) ;
930+
931+ // The retry is a new run picking up the session, booting its history from the chunks
932+ // the failed one left on `session.out`.
933+ const repairing = appendingStore ( table , ( ) => false , { openInvestigation : seeded } ) ;
934+ harness = mockChatAgent ( dashboardAgent , {
935+ chatId,
936+ clientData : CLIENT_DATA_WITH_TOKEN ,
937+ continuation : true ,
938+ previousRunId : "run_investigate_failed" ,
939+ setupLocals : ( { set } ) => {
940+ set ( dashboardAgentStoreKey , repairing . store ) ;
941+ set ( dashboardAgentModelKey , recordingModel ( steps ) . model ) ;
942+ } ,
943+ } ) ;
944+ harness . seedSessionOutTail ( durable ) ;
945+
946+ const retry = await harness . sendAction ( INVESTIGATE ) ;
947+
948+ // Nothing investigated a second time, and the display copy converged on one row.
949+ expect ( collectText ( retry . chunks ) ) . toBe ( "" ) ;
950+ expect ( table . countOf ( chatId , findingsId ) ) . toBe ( 1 ) ;
951+
952+ // A third delivery repairs nothing, because there is nothing left to repair.
953+ await harness . sendAction ( INVESTIGATE ) ;
954+ expect ( table . countOf ( chatId , findingsId ) ) . toBe ( 1 ) ;
955+ } ) ;
790956} ) ;
0 commit comments