Skip to content

Commit 3525273

Browse files
committed
fix: keep TUI watchdog bound to active run (#67401) (thanks @xantorres)
1 parent d7f489f commit 3525273

2 files changed

Lines changed: 47 additions & 24 deletions

File tree

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

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -721,9 +721,7 @@ describe("tui-event-handlers: streaming watchdog", () => {
721721

722722
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
723723
expect(state.activeChatRunId).toBeNull();
724-
expect(chatLog.addSystem).toHaveBeenCalledWith(
725-
expect.stringContaining("streaming watchdog"),
726-
);
724+
expect(chatLog.addSystem).toHaveBeenCalledWith(expect.stringContaining("streaming watchdog"));
727725

728726
handlers.dispose?.();
729727
});
@@ -751,8 +749,6 @@ describe("tui-event-handlers: streaming watchdog", () => {
751749

752750
vi.advanceTimersByTime(3_000);
753751

754-
// 6s total, but the latest delta was only 3s ago, so the watchdog must not
755-
// have fired yet.
756752
expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
757753
expect(state.activeChatRunId).toBe("run-flow");
758754

@@ -784,8 +780,6 @@ describe("tui-event-handlers: streaming watchdog", () => {
784780

785781
vi.advanceTimersByTime(10_000);
786782

787-
// After a normal final, the watchdog timer must have been cancelled and
788-
// cannot later re-overwrite the status or emit the warning banner.
789783
const statusCalls = setActivityStatus.mock.calls.map((c) => c[0]);
790784
expect(statusCalls.filter((s) => s === "idle").length).toBe(1);
791785
expect(chatLog.addSystem).not.toHaveBeenCalledWith(
@@ -817,6 +811,47 @@ describe("tui-event-handlers: streaming watchdog", () => {
817811
handlers.dispose?.();
818812
});
819813

814+
it("does not let an older run steal the active run watchdog", () => {
815+
const { state, chatLog, setActivityStatus, handlers } = createHarness({
816+
streamingWatchdogMs: 5_000,
817+
});
818+
819+
handlers.handleChatEvent({
820+
runId: "run-old",
821+
sessionKey: state.currentSessionKey,
822+
state: "delta",
823+
message: { content: "old" },
824+
} satisfies ChatEvent);
825+
826+
vi.advanceTimersByTime(5_001);
827+
expect(state.activeChatRunId).toBeNull();
828+
829+
handlers.handleChatEvent({
830+
runId: "run-new",
831+
sessionKey: state.currentSessionKey,
832+
state: "delta",
833+
message: { content: "new" },
834+
} satisfies ChatEvent);
835+
expect(state.activeChatRunId).toBe("run-new");
836+
837+
vi.advanceTimersByTime(3_000);
838+
839+
handlers.handleChatEvent({
840+
runId: "run-old",
841+
sessionKey: state.currentSessionKey,
842+
state: "delta",
843+
message: { content: "old again" },
844+
} satisfies ChatEvent);
845+
846+
vi.advanceTimersByTime(2_001);
847+
848+
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
849+
expect(state.activeChatRunId).toBeNull();
850+
expect(chatLog.addSystem).toHaveBeenCalledTimes(2);
851+
852+
handlers.dispose?.();
853+
});
854+
820855
it("dispose clears a pending watchdog without firing it", () => {
821856
const { setActivityStatus, chatLog, handlers, state } = createHarness({
822857
streamingWatchdogMs: 5_000,

src/tui/tui-event-handlers.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,7 @@ type EventHandlerContext = {
4141
isLocalBtwRunId?: (runId: string) => boolean;
4242
forgetLocalBtwRunId?: (runId: string) => void;
4343
clearLocalBtwRunIds?: () => void;
44-
/**
45-
* Milliseconds of stream-delta silence that force the `streaming` activity
46-
* indicator to reset to `idle`. Guards against lost/late "final" events from
47-
* the gateway (WS flaps, gateway restarts, backends that emit `stopReason`
48-
* without an explicit stream-end event) leaving the TUI stuck on
49-
* `streaming · Xm Ys` forever. Defaults to 30s. Set to 0 to disable.
50-
*/
44+
/** Reset `streaming` after this much delta silence. Set to 0 to disable. */
5145
streamingWatchdogMs?: number;
5246
};
5347

@@ -103,16 +97,10 @@ export function createEventHandlers(context: EventHandlerContext) {
10397
streamingWatchdogRunId = runId;
10498
streamingWatchdogTimer = setTimeout(() => {
10599
streamingWatchdogTimer = null;
106-
// Only act if the timer still matches the run that armed it and that run
107-
// is still the TUI's active stream. A later `final`/`aborted`/`error`
108-
// event already cleared the indicator by the normal path otherwise.
109-
if (streamingWatchdogRunId !== runId) {
100+
if (streamingWatchdogRunId !== runId || state.activeChatRunId !== runId) {
110101
return;
111102
}
112103
streamingWatchdogRunId = null;
113-
if (state.activeChatRunId !== runId) {
114-
return;
115-
}
116104
state.activeChatRunId = null;
117105
setActivityStatus("idle");
118106
chatLog.addSystem(
@@ -122,8 +110,6 @@ export function createEventHandlers(context: EventHandlerContext) {
122110
);
123111
tui.requestRender();
124112
}, streamingWatchdogMs);
125-
// Keep the watchdog from blocking process exit when the TUI is shutting
126-
// down. Node timers expose unref() on the returned Timeout object.
127113
const maybeUnref = (streamingWatchdogTimer as { unref?: () => void }).unref;
128114
if (typeof maybeUnref === "function") {
129115
maybeUnref.call(streamingWatchdogTimer);
@@ -318,7 +304,9 @@ export function createEventHandlers(context: EventHandlerContext) {
318304
}
319305
chatLog.updateAssistant(displayText, evt.runId);
320306
setActivityStatus("streaming");
321-
armStreamingWatchdog(evt.runId);
307+
if (state.activeChatRunId === evt.runId) {
308+
armStreamingWatchdog(evt.runId);
309+
}
322310
}
323311
if (evt.state === "final") {
324312
const isLocalBtwRun = isLocalBtwRunId?.(evt.runId) ?? false;

0 commit comments

Comments
 (0)