Skip to content

Commit 54d2644

Browse files
author
chenyangjun-xy
committed
fix(tui): preserve in-flight run after sessions.changed history reload
When sessions.changed new fires mid-stream, loadHistory() clears the chat log and replays the message as a static entry. If the final chat event arrives after that replay, finalizeAssistant would append a duplicate. Add a surrenderedToHistoryRunIds tracker with three defense layers: 1. Proximity gating — capture all tracked runIds before loadHistory() 2. 15s TTL cleanup — auto-expire to avoid permanent blocking 3. Mid-stream gating — hasStreamingRun() check to distinguish in-flight restores from static replays
1 parent 9c95abd commit 54d2644

3 files changed

Lines changed: 120 additions & 0 deletions

File tree

src/tui/components/chat-log.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,10 @@ export class ChatLog extends Container {
323323
this.streamingRuns.delete(effectiveRunId);
324324
}
325325

326+
hasStreamingRun(runId?: string) {
327+
return this.streamingRuns.has(this.resolveRunId(runId));
328+
}
329+
326330
showBtw(params: { question: string; text: string; isError?: boolean }) {
327331
if (this.btwMessage) {
328332
this.btwMessage.setResult(params);

src/tui/tui-event-handlers.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type HandlerChatLog = {
2020
updateAssistant: (...args: unknown[]) => void;
2121
finalizeAssistant: (...args: unknown[]) => void;
2222
dropAssistant: (...args: unknown[]) => void;
23+
hasStreamingRun: (...args: unknown[]) => boolean;
2324
};
2425
type HandlerBtwPresenter = {
2526
showResult: (...args: unknown[]) => void;
@@ -35,6 +36,7 @@ type MockChatLog = {
3536
updateAssistant: MockFn;
3637
finalizeAssistant: MockFn;
3738
dropAssistant: MockFn;
39+
hasStreamingRun: MockFn;
3840
};
3941
type MockBtwPresenter = {
4042
showResult: MockFn;
@@ -52,6 +54,7 @@ function createMockChatLog(): MockChatLog & HandlerChatLog {
5254
updateAssistant: vi.fn(),
5355
finalizeAssistant: vi.fn(),
5456
dropAssistant: vi.fn(),
57+
hasStreamingRun: vi.fn().mockReturnValue(false),
5558
} as unknown as MockChatLog & HandlerChatLog;
5659
}
5760

src/tui/tui-event-handlers.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3132
type 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

Comments
 (0)