@@ -26,6 +26,7 @@ type EventHandlerChatLog = {
2626 updateAssistant : ( text : string , runId : string ) => void ;
2727 finalizeAssistant : ( text : string , runId : string ) => void ;
2828 dropAssistant : ( runId : string ) => void ;
29+ hasStreamingRun : ( runId : string ) => boolean ;
2930} ;
3031
3132type EventHandlerTui = {
@@ -89,6 +90,16 @@ export function createEventHandlers(context: EventHandlerContext) {
8990 let lastSessionKey = state . currentSessionKey ;
9091 let pendingHistoryRefresh = false ;
9192 let reconnectPendingRunId : string | null = null ;
93+ // RunIds surrendered to a pending loadHistory() call from
94+ // handleSessionsChangedEvent. When the sessions.changed "new" event fires
95+ // after a chat run starts streaming, loadHistory() clears the chat log and
96+ // replays the message as a static entry. If the final chat event arrives
97+ // AFTER that replay, finalizeAssistant creates a duplicate because the
98+ // streaming component was already removed. Tracking surrendered runIds lets
99+ // us skip the late final event and avoid the duplicate.
100+ const surrenderedToHistoryRunIds = new Set < string > ( ) ;
101+ let surrenderedToHistoryCleanupTimer : ReturnType < typeof setTimeout > | null = null ;
102+ const SURRENDERED_TO_HISTORY_CLEANUP_MS = 15_000 ;
92103 const pendingTerminalLifecycleErrors = new Map <
93104 string ,
94105 { errorMessage : string ; timer : ReturnType < typeof setTimeout > }
@@ -140,6 +151,17 @@ export function createEventHandlers(context: EventHandlerContext) {
140151 pendingTerminalLifecycleErrors . clear ( ) ;
141152 } ;
142153
154+ const scheduleSurrenderedRunIdsCleanup = ( ) => {
155+ if ( surrenderedToHistoryCleanupTimer ) {
156+ clearTimeout ( surrenderedToHistoryCleanupTimer ) ;
157+ }
158+ surrenderedToHistoryCleanupTimer = setTimeout ( ( ) => {
159+ surrenderedToHistoryRunIds . clear ( ) ;
160+ surrenderedToHistoryCleanupTimer = null ;
161+ } , SURRENDERED_TO_HISTORY_CLEANUP_MS ) ;
162+ surrenderedToHistoryCleanupTimer . unref ?.( ) ;
163+ } ;
164+
143165 const pauseStreamingWatchdog = ( ) => {
144166 clearStreamingWatchdog ( ) ;
145167 } ;
@@ -611,6 +633,66 @@ export function createEventHandlers(context: EventHandlerContext) {
611633 }
612634 }
613635 }
636+ // When a sessions.changed "new" event surrendered this runId to a
637+ // pending loadHistory() call, the history replay may have already
638+ // rendered the message as a static entry. If loadHistory() restored
639+ // the run as in-flight (via inFlightRun), the runId will have an
640+ // active streaming component — do NOT suppress it, because the
641+ // in-flight streaming component needs live delta/final events to
642+ // reach its final state. Only suppress when there is no active
643+ // streaming component, meaning the message was replayed as a static
644+ // entry and a late final event would append a duplicate.
645+ if ( surrenderedToHistoryRunIds . has ( evt . runId ) ) {
646+ // loadHistory() restored this run as in-flight — keep processing
647+ // normally so the streaming component stays live.
648+ if ( chatLog . hasStreamingRun ( evt . runId ) ) {
649+ surrenderedToHistoryRunIds . delete ( evt . runId ) ;
650+ // Fall through to normal event handling below.
651+ } else {
652+ if ( evt . state === "delta" ) {
653+ return ;
654+ }
655+ if ( evt . state === "final" ) {
656+ surrenderedToHistoryRunIds . delete ( evt . runId ) ;
657+ // Still finalize the run's tracking state so the activity
658+ // indicator and pending-run cleanup stay consistent, but do
659+ // not re-render the message text — history replay already
660+ // displayed it as a static entry.
661+ noteFinalizedRun ( evt . runId ) ;
662+ const wasActiveRun = state . activeChatRunId === evt . runId ;
663+ clearActiveRunIfMatch ( evt . runId ) ;
664+ const promotedRemainingRun = promoteMostRecentSessionRun ( ) ;
665+ flushPendingHistoryRefreshIfIdle ( ) ;
666+ if ( ! promotedRemainingRun ) {
667+ if ( wasActiveRun ) {
668+ setActivityStatus ( "idle" ) ;
669+ clearStreamingWatchdog ( ) ;
670+ } else {
671+ if ( streamingWatchdogRunId === evt . runId ) {
672+ clearStreamingWatchdog ( ) ;
673+ }
674+ clearStaleStreamingIfNoTrackedRunRemains ( ) ;
675+ }
676+ }
677+ void refreshSessionInfo ?.( ) ;
678+ tui . requestRender ( ) ;
679+ return ;
680+ }
681+ // For aborted / error states after surrender, clean up but
682+ // don't render duplicate history entries.
683+ if ( evt . state === "aborted" || evt . state === "error" ) {
684+ surrenderedToHistoryRunIds . delete ( evt . runId ) ;
685+ completedRuns . set ( evt . runId , Date . now ( ) ) ;
686+ pruneRunMap ( completedRuns ) ;
687+ streamAssembler . drop ( evt . runId ) ;
688+ clearActiveRunIfMatch ( evt . runId ) ;
689+ promoteMostRecentSessionRun ( ) ;
690+ flushPendingHistoryRefreshIfIdle ( ) ;
691+ tui . requestRender ( ) ;
692+ return ;
693+ }
694+ }
695+ }
614696 if ( reconnectPendingRunId === evt . runId ) {
615697 reconnectPendingRunId = null ;
616698 }
@@ -747,6 +829,32 @@ export function createEventHandlers(context: EventHandlerContext) {
747829 return ;
748830 }
749831
832+ // When a sessions.changed "new" event fires while a chat run is still
833+ // streaming, the subsequent loadHistory() will clear the chat log and
834+ // replay the message as a static entry. If the final chat event arrives
835+ // after that replay, finalizeAssistant would append a duplicate because
836+ // the streaming component is already gone. Save every tracked runId so
837+ // handleChatEvent can skip late-arriving deltas/finals that history
838+ // replay already covered.
839+ if ( evt . reason === "new" ) {
840+ if ( state . activeChatRunId ) {
841+ surrenderedToHistoryRunIds . add ( state . activeChatRunId ) ;
842+ }
843+ if ( state . pendingChatRunId ) {
844+ surrenderedToHistoryRunIds . add ( state . pendingChatRunId ) ;
845+ }
846+ for ( const runId of sessionRuns . keys ( ) ) {
847+ surrenderedToHistoryRunIds . add ( runId ) ;
848+ }
849+ // finalizedRuns entries are already resolved; include them so a
850+ // duplicate final event (rare, but possible during reconnect storms)
851+ // is also suppressed.
852+ for ( const runId of finalizedRuns . keys ( ) ) {
853+ surrenderedToHistoryRunIds . add ( runId ) ;
854+ }
855+ scheduleSurrenderedRunIdsCleanup ( ) ;
856+ }
857+
750858 clearTrackedRunState ( ) ;
751859 state . activeChatRunId = null ;
752860 state . activityStatus = "idle" ;
@@ -953,6 +1061,11 @@ export function createEventHandlers(context: EventHandlerContext) {
9531061 const dispose = ( ) => {
9541062 clearStreamingWatchdog ( ) ;
9551063 clearPendingTerminalLifecycleErrors ( ) ;
1064+ if ( surrenderedToHistoryCleanupTimer ) {
1065+ clearTimeout ( surrenderedToHistoryCleanupTimer ) ;
1066+ surrenderedToHistoryCleanupTimer = null ;
1067+ }
1068+ surrenderedToHistoryRunIds . clear ( ) ;
9561069 } ;
9571070
9581071 const consumeCompletedRunForPendingSend = ( runId : string ) => {
0 commit comments