@@ -145,6 +145,7 @@ function createContext() {
145145 getState : vi . fn ( ( ) => ( { sessions } ) ) ,
146146 addExternalSession : vi . fn ( ) ,
147147 updateSessionDispatchTarget : vi . fn ( ) ,
148+ updateSessionTitle : vi . fn ( ) ,
148149 applyDispatchSnapshot : vi . fn ( (
149150 sessionId : string ,
150151 snapshot : { cursor : number ; state : string } ,
@@ -359,6 +360,82 @@ describe('DispatchJobObserver', () => {
359360 vi . useRealTimers ( ) ;
360361 } ) ;
361362
363+ it . each ( [ 'ssh' , 'device' ] as const ) ( 'persists %s generated titles and restores them after projection recreation' , async ( kind ) => {
364+ registerRunningJob ( ) ;
365+ if ( kind === 'device' ) {
366+ const target = { kind : 'device' as const , deviceId : 'device-1' , workspacePath : '/repo' , displayName : 'peer' } ;
367+ dispatchJobStore . getState ( ) . registerJob ( { ...dispatchJobStore . getState ( ) . jobs [ 'job-1' ] , target, targetRequest : target } ) ;
368+ mocks . listJobs . mockResolvedValue ( [ { ...runningOutboundRecord ( ) , target } ] ) ;
369+ }
370+ installProcessingProjection ( ) ;
371+ const projection = flowChatStore . getState ( ) . sessions . get ( 'session-1' ) ! ;
372+ projection . config . dispatchTarget = dispatchJobStore . getState ( ) . jobs [ 'job-1' ] . target ;
373+ mocks . status . mockResolvedValue ( status ( { cursor : 50 , events : [ {
374+ type : 'agentEvent' , timestamp : '2026-07-28T00:00:02Z' ,
375+ event : { id : 'title-1' , event : { type : 'SessionTitleGenerated' , session_id : 'session-1' , title : 'Investigate build failure' } } ,
376+ } ] } ) ) ;
377+ const cleanup = installDispatchJobObserver ( createTerminalContext ( ) ) ;
378+ await vi . advanceTimersByTimeAsync ( 0 ) ;
379+ expect ( flowChatStore . getState ( ) . sessions . get ( 'session-1' ) ?. title ) . toBe ( 'Investigate build failure' ) ;
380+ expect ( dispatchJobStore . getState ( ) . jobs [ 'job-1' ] ) . toMatchObject ( { title : 'Investigate build failure' , titleSource : 'generated' } ) ;
381+ cleanup ( ) ;
382+
383+ // A recreated renderer resumes from its paired transcript cursor, which
384+ // may already be past the title event, while the index still has the default.
385+ const storage = dispatchJobStore . persist . getOptions ( ) . storage ! ;
386+ const persisted = ( await storage . getItem ( 'openbitfun-dispatch-jobs-v1' ) ) ! ;
387+ dispatchJobStore . setState ( { jobs : { } } ) ;
388+ await storage . setItem ( 'openbitfun-dispatch-jobs-v1' , persisted ) ;
389+ await dispatchJobStore . persist . rehydrate ( ) ;
390+ flowChatStore . setState ( ( ) => ( { sessions : new Map ( ) , activeSessionId : null } ) ) ;
391+ mocks . loadTranscript . mockResolvedValue ( cachedTranscript ( { cursor : 50 } ) ) ;
392+ mocks . status . mockResolvedValue ( status ( { cursor : 50 , events : [ ] } ) ) ;
393+ const restored = installDispatchJobObserver ( createTerminalContext ( ) ) ;
394+ await vi . advanceTimersByTimeAsync ( 0 ) ;
395+ expect ( flowChatStore . getState ( ) . sessions . get ( 'session-1' ) ?. title ) . toBe ( 'Investigate build failure' ) ;
396+ restored ( ) ;
397+ } ) ;
398+
399+ it ( 'keeps a manual name when a legacy target title is replayed' , async ( ) => {
400+ registerRunningJob ( ) ;
401+ dispatchJobStore . getState ( ) . updateTitle ( 'job-1' , 'My investigation' ) ;
402+ installProcessingProjection ( ) ;
403+ mocks . status . mockResolvedValue ( status ( { cursor : 50 , events : [ {
404+ type : 'agentEvent' , timestamp : '2026-07-28T00:00:02Z' ,
405+ event : { frontendEventName : 'session_title_generated' , frontendPayload : { sessionId : 'session-1' , title : 'Old generated name' } } ,
406+ } ] } ) ) ;
407+ const cleanup = installDispatchJobObserver ( createTerminalContext ( ) ) ;
408+ await vi . advanceTimersByTimeAsync ( 0 ) ;
409+ expect ( flowChatStore . getState ( ) . sessions . get ( 'session-1' ) ?. title ) . toBe ( 'My investigation' ) ;
410+ expect ( dispatchJobStore . getState ( ) . jobs [ 'job-1' ] . title ) . toBe ( 'My investigation' ) ;
411+ cleanup ( ) ;
412+ } ) ;
413+
414+ it ( 'replays a pre-title transcript cache so the generated name is recovered after upgrade' , async ( ) => {
415+ registerRunningJob ( { cursor : 50 , appliedEventIds : [ 'title-1' ] } ) ;
416+ mocks . loadTranscript . mockResolvedValue ( cachedTranscript ( { schemaVersion : 4 , cursor : 50 } ) ) ;
417+ mocks . status . mockResolvedValue ( status ( { cursor : 50 , events : [ {
418+ type : 'agentEvent' , timestamp : '2026-07-28T00:00:02Z' ,
419+ event : { id : 'title-1' , event : { type : 'SessionTitleGenerated' , session_id : 'session-1' , title : 'Recovered title' } } ,
420+ } ] } ) ) ;
421+ const cleanup = installDispatchJobObserver ( createTerminalContext ( ) ) ;
422+ await vi . advanceTimersByTimeAsync ( 0 ) ;
423+ expect ( mocks . status ) . toHaveBeenCalledWith ( 'job-1' , 0 ) ;
424+ expect ( flowChatStore . getState ( ) . sessions . get ( 'session-1' ) ?. title ) . toBe ( 'Recovered title' ) ;
425+ cleanup ( ) ;
426+ } ) ;
427+
428+ it ( 'restores title metadata from the transcript when the renderer cache is missing' , async ( ) => {
429+ mocks . listJobs . mockResolvedValue ( [ runningOutboundRecord ( ) ] ) ;
430+ mocks . loadTranscript . mockResolvedValue ( cachedTranscript ( { title : 'Cached investigation' , titleSource : 'generated' } ) ) ;
431+ mocks . status . mockResolvedValue ( status ( { cursor : 50 , events : [ ] } ) ) ;
432+ const cleanup = installDispatchJobObserver ( createTerminalContext ( ) ) ;
433+ await vi . advanceTimersByTimeAsync ( 0 ) ;
434+ expect ( flowChatStore . getState ( ) . sessions . get ( 'session-1' ) ?. title ) . toBe ( 'Cached investigation' ) ;
435+ expect ( dispatchJobStore . getState ( ) . jobs [ 'job-1' ] ) . toMatchObject ( { title : 'Cached investigation' , titleSource : 'generated' } ) ;
436+ cleanup ( ) ;
437+ } ) ;
438+
362439 it ( 'projects raw target events into the existing frontend event contract' , ( ) => {
363440 const projected = projectDispatchAgentEvent ( {
364441 type : 'agentEvent' ,
0 commit comments