Skip to content

Commit f44ab20

Browse files
xantorresobviyus
authored andcommitted
TUI/streaming: add watchdog that resets the activity indicator after delta silence
1 parent 36ed367 commit f44ab20

3 files changed

Lines changed: 264 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
3838
- Agents/failover: treat HTML provider error pages as upstream transport failures for CDN-style 5xx responses without misclassifying embedded body text as API rate limits, while still preserving auth remediation for HTML 401/403 pages and proxy remediation for HTML 407 pages. (#67642) Thanks @stainlu.
3939
- Gateway/skills: bump the cached skills-snapshot version whenever a config write touches `skills.*` (for example `skills.allowBundled`, `skills.entries.<id>.enabled`, or `skills.profile`). Existing agent sessions persist a `skillsSnapshot` in `sessions.json` that reuses the skill list frozen at session creation; without this invalidation, removing a bundled skill from the allowlist left the old snapshot live and the model kept calling the disabled tool, producing `Tool <name> not found` loops that ran until the embedded-run timeout. (#67401) Thanks @xantorres.
4040
- Agents/tool-loop: enable the unknown-tool stream guard by default. Previously `resolveUnknownToolGuardThreshold` returned `undefined` unless `tools.loopDetection.enabled` was explicitly set to `true`, which left the protection off in the default configuration. A hallucinated or removed tool (for example `himalaya` after it was dropped from `skills.allowBundled`) would then loop "Tool X not found" attempts until the full embedded-run timeout. The guard has no false-positive surface because it only triggers on tools that are objectively not registered in the run, so it now stays on regardless of `tools.loopDetection.enabled` and still accepts `tools.loopDetection.unknownToolThreshold` as a per-run override (default 10). (#67401) Thanks @xantorres.
41+
- TUI/streaming: add a client-side streaming watchdog to `tui-event-handlers` so the `streaming · Xm Ys` activity indicator resets to `idle` after 30s of delta silence on the active run. Guards against lost or late `state: "final"` chat events (WS reconnects, gateway restarts, etc.) leaving the TUI stuck on `streaming` indefinitely; a new system log line surfaces the reset so users know to send a new message to resync. The window is configurable via the new `streamingWatchdogMs` context option (set to `0` to disable), and the handler now exposes a `dispose()` that clears the pending timer on shutdown. (#67401) Thanks @xantorres.
4142

4243
## 2026.4.15-beta.1
4344

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

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { createEventHandlers } from "./tui-event-handlers.js";
33
import type { AgentEvent, BtwEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
44

@@ -651,3 +651,188 @@ describe("tui-event-handlers: handleAgentEvent", () => {
651651
expect(loadHistory).toHaveBeenCalledTimes(1);
652652
});
653653
});
654+
655+
describe("tui-event-handlers: streaming watchdog", () => {
656+
beforeEach(() => {
657+
vi.useFakeTimers();
658+
});
659+
660+
afterEach(() => {
661+
vi.useRealTimers();
662+
});
663+
664+
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
665+
agentDefaultId: "main",
666+
sessionMainKey: "agent:main:main",
667+
sessionScope: "global",
668+
agents: [],
669+
currentAgentId: "main",
670+
currentSessionKey: "agent:main:main",
671+
currentSessionId: "session-1",
672+
activeChatRunId: null,
673+
pendingOptimisticUserMessage: false,
674+
historyLoaded: true,
675+
sessionInfo: { verboseLevel: "on" },
676+
initialSessionApplied: true,
677+
isConnected: true,
678+
autoMessageSent: false,
679+
toolsExpanded: false,
680+
showThinking: false,
681+
connectionStatus: "connected",
682+
activityStatus: "idle",
683+
statusTimeout: null,
684+
lastCtrlCAt: 0,
685+
...overrides,
686+
});
687+
688+
const createHarness = (options?: { streamingWatchdogMs?: number }) => {
689+
const state = makeState();
690+
const chatLog = createMockChatLog();
691+
const btw = createMockBtwPresenter();
692+
const tui = { requestRender: vi.fn() } as unknown as MockTui & HandlerTui;
693+
const setActivityStatus = vi.fn();
694+
const handlers = createEventHandlers({
695+
chatLog,
696+
btw,
697+
tui,
698+
state,
699+
setActivityStatus,
700+
streamingWatchdogMs: options?.streamingWatchdogMs,
701+
});
702+
return { state, chatLog, tui, setActivityStatus, handlers };
703+
};
704+
705+
it("resets activityStatus to idle when no stream delta arrives for the watchdog window", () => {
706+
const { state, chatLog, setActivityStatus, handlers } = createHarness({
707+
streamingWatchdogMs: 5_000,
708+
});
709+
710+
handlers.handleChatEvent({
711+
runId: "run-stuck",
712+
sessionKey: state.currentSessionKey,
713+
state: "delta",
714+
message: { content: "hello" },
715+
} satisfies ChatEvent);
716+
717+
expect(setActivityStatus).toHaveBeenLastCalledWith("streaming");
718+
expect(state.activeChatRunId).toBe("run-stuck");
719+
720+
vi.advanceTimersByTime(5_001);
721+
722+
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
723+
expect(state.activeChatRunId).toBeNull();
724+
expect(chatLog.addSystem).toHaveBeenCalledWith(
725+
expect.stringContaining("streaming watchdog"),
726+
);
727+
728+
handlers.dispose?.();
729+
});
730+
731+
it("refreshes the watchdog window on each new stream delta", () => {
732+
const { state, setActivityStatus, handlers } = createHarness({
733+
streamingWatchdogMs: 5_000,
734+
});
735+
736+
handlers.handleChatEvent({
737+
runId: "run-flow",
738+
sessionKey: state.currentSessionKey,
739+
state: "delta",
740+
message: { content: "first" },
741+
} satisfies ChatEvent);
742+
743+
vi.advanceTimersByTime(3_000);
744+
745+
handlers.handleChatEvent({
746+
runId: "run-flow",
747+
sessionKey: state.currentSessionKey,
748+
state: "delta",
749+
message: { content: "second" },
750+
} satisfies ChatEvent);
751+
752+
vi.advanceTimersByTime(3_000);
753+
754+
// 6s total, but the latest delta was only 3s ago, so the watchdog must not
755+
// have fired yet.
756+
expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
757+
expect(state.activeChatRunId).toBe("run-flow");
758+
759+
vi.advanceTimersByTime(2_500);
760+
761+
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
762+
expect(state.activeChatRunId).toBeNull();
763+
764+
handlers.dispose?.();
765+
});
766+
767+
it("cancels the watchdog when the run finalizes normally", () => {
768+
const { state, chatLog, setActivityStatus, handlers } = createHarness({
769+
streamingWatchdogMs: 5_000,
770+
});
771+
772+
handlers.handleChatEvent({
773+
runId: "run-normal",
774+
sessionKey: state.currentSessionKey,
775+
state: "delta",
776+
message: { content: "hi" },
777+
} satisfies ChatEvent);
778+
handlers.handleChatEvent({
779+
runId: "run-normal",
780+
sessionKey: state.currentSessionKey,
781+
state: "final",
782+
message: { content: [{ type: "text", text: "done" }], stopReason: "stop" },
783+
} satisfies ChatEvent);
784+
785+
vi.advanceTimersByTime(10_000);
786+
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.
789+
const statusCalls = setActivityStatus.mock.calls.map((c) => c[0]);
790+
expect(statusCalls.filter((s) => s === "idle").length).toBe(1);
791+
expect(chatLog.addSystem).not.toHaveBeenCalledWith(
792+
expect.stringContaining("streaming watchdog"),
793+
);
794+
expect(state.activeChatRunId).toBeNull();
795+
796+
handlers.dispose?.();
797+
});
798+
799+
it("is disabled when streamingWatchdogMs is 0", () => {
800+
const { state, chatLog, setActivityStatus, handlers } = createHarness({
801+
streamingWatchdogMs: 0,
802+
});
803+
804+
handlers.handleChatEvent({
805+
runId: "run-no-watchdog",
806+
sessionKey: state.currentSessionKey,
807+
state: "delta",
808+
message: { content: "hi" },
809+
} satisfies ChatEvent);
810+
811+
vi.advanceTimersByTime(60_000);
812+
813+
expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
814+
expect(chatLog.addSystem).not.toHaveBeenCalled();
815+
expect(state.activeChatRunId).toBe("run-no-watchdog");
816+
817+
handlers.dispose?.();
818+
});
819+
820+
it("dispose clears a pending watchdog without firing it", () => {
821+
const { setActivityStatus, chatLog, handlers, state } = createHarness({
822+
streamingWatchdogMs: 5_000,
823+
});
824+
825+
handlers.handleChatEvent({
826+
runId: "run-dispose",
827+
sessionKey: state.currentSessionKey,
828+
state: "delta",
829+
message: { content: "hi" },
830+
} satisfies ChatEvent);
831+
832+
handlers.dispose?.();
833+
vi.advanceTimersByTime(10_000);
834+
835+
expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
836+
expect(chatLog.addSystem).not.toHaveBeenCalled();
837+
});
838+
});

src/tui/tui-event-handlers.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,18 @@ 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+
*/
51+
streamingWatchdogMs?: number;
4452
};
4553

54+
const DEFAULT_STREAMING_WATCHDOG_MS = 30_000;
55+
4656
export function createEventHandlers(context: EventHandlerContext) {
4757
const {
4858
chatLog,
@@ -66,6 +76,60 @@ export function createEventHandlers(context: EventHandlerContext) {
6676
let lastSessionKey = state.currentSessionKey;
6777
let pendingHistoryRefresh = false;
6878

79+
const streamingWatchdogMs =
80+
typeof context.streamingWatchdogMs === "number" &&
81+
Number.isFinite(context.streamingWatchdogMs) &&
82+
context.streamingWatchdogMs >= 0
83+
? Math.floor(context.streamingWatchdogMs)
84+
: DEFAULT_STREAMING_WATCHDOG_MS;
85+
let streamingWatchdogTimer: ReturnType<typeof setTimeout> | null = null;
86+
let streamingWatchdogRunId: string | null = null;
87+
88+
const clearStreamingWatchdog = () => {
89+
if (streamingWatchdogTimer) {
90+
clearTimeout(streamingWatchdogTimer);
91+
streamingWatchdogTimer = null;
92+
}
93+
streamingWatchdogRunId = null;
94+
};
95+
96+
const armStreamingWatchdog = (runId: string) => {
97+
if (streamingWatchdogMs <= 0) {
98+
return;
99+
}
100+
if (streamingWatchdogTimer) {
101+
clearTimeout(streamingWatchdogTimer);
102+
}
103+
streamingWatchdogRunId = runId;
104+
streamingWatchdogTimer = setTimeout(() => {
105+
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) {
110+
return;
111+
}
112+
streamingWatchdogRunId = null;
113+
if (state.activeChatRunId !== runId) {
114+
return;
115+
}
116+
state.activeChatRunId = null;
117+
setActivityStatus("idle");
118+
chatLog.addSystem(
119+
`streaming watchdog: no stream updates for ${Math.round(
120+
streamingWatchdogMs / 1000,
121+
)}s; resetting status. The backend may have dropped this run silently — send a new message to resync.`,
122+
);
123+
tui.requestRender();
124+
}, 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.
127+
const maybeUnref = (streamingWatchdogTimer as { unref?: () => void }).unref;
128+
if (typeof maybeUnref === "function") {
129+
maybeUnref.call(streamingWatchdogTimer);
130+
}
131+
};
132+
69133
const pruneRunMap = (runs: Map<string, number>) => {
70134
if (runs.size <= 200) {
71135
return;
@@ -102,6 +166,7 @@ export function createEventHandlers(context: EventHandlerContext) {
102166
clearLocalRunIds?.();
103167
clearLocalBtwRunIds?.();
104168
btw.clear();
169+
clearStreamingWatchdog();
105170
};
106171

107172
const flushPendingHistoryRefreshIfIdle = () => {
@@ -140,6 +205,9 @@ export function createEventHandlers(context: EventHandlerContext) {
140205
flushPendingHistoryRefreshIfIdle();
141206
if (params.wasActiveRun) {
142207
setActivityStatus(params.status);
208+
clearStreamingWatchdog();
209+
} else if (streamingWatchdogRunId === params.runId) {
210+
clearStreamingWatchdog();
143211
}
144212
void refreshSessionInfo?.();
145213
};
@@ -155,6 +223,9 @@ export function createEventHandlers(context: EventHandlerContext) {
155223
flushPendingHistoryRefreshIfIdle();
156224
if (params.wasActiveRun) {
157225
setActivityStatus(params.status);
226+
clearStreamingWatchdog();
227+
} else if (streamingWatchdogRunId === params.runId) {
228+
clearStreamingWatchdog();
158229
}
159230
void refreshSessionInfo?.();
160231
};
@@ -247,6 +318,7 @@ export function createEventHandlers(context: EventHandlerContext) {
247318
}
248319
chatLog.updateAssistant(displayText, evt.runId);
249320
setActivityStatus("streaming");
321+
armStreamingWatchdog(evt.runId);
250322
}
251323
if (evt.state === "final") {
252324
const isLocalBtwRun = isLocalBtwRunId?.(evt.runId) ?? false;
@@ -412,5 +484,9 @@ export function createEventHandlers(context: EventHandlerContext) {
412484
tui.requestRender();
413485
};
414486

415-
return { handleChatEvent, handleAgentEvent, handleBtwEvent };
487+
const dispose = () => {
488+
clearStreamingWatchdog();
489+
};
490+
491+
return { handleChatEvent, handleAgentEvent, handleBtwEvent, dispose };
416492
}

0 commit comments

Comments
 (0)