From b741ddb66f5ebdf0ac77b9d61dbdc8c8be24abc1 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 22 May 2026 12:02:36 -0700 Subject: [PATCH] fix(tui): dismiss watchdog notice when response actually arrives (#77375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tui): dismiss watchdog notice when response actually arrives The streaming watchdog renders 'This response is taking longer than expected. Send another message to continue.' after 30s without a chat delta. If a delta or final then arrives — common for runs that are slow but not stuck — the notice stays in the log alongside the recovered response and contradicts what the user sees. Track the notice by runId in the chat log via a new `addPendingSystem` + `dismissPendingSystem` pair (mirroring the existing pendingUsers pattern) and dismiss it from `handleChatEvent` whenever any further chat event for that run is processed. The watchdog's internal cleanup (`activeChatRunId` reset, status idle, history reload) is unchanged. Refs #67052, #69081 (closed). Prior attempt #69026 raised the threshold and suppressed the notice entirely; this is the narrower fix that keeps the warning useful for genuinely stuck runs. * fix(tui): adapt pending notice to repeatable system entries --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/tui/components/chat-log.test.ts | 27 +++++++++++ src/tui/components/chat-log.ts | 27 +++++++++++ src/tui/tui-event-handlers.test.ts | 74 ++++++++++++++++++++++++++--- src/tui/tui-event-handlers.ts | 5 +- 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6174b6b5e..7fd89a1f7b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,7 @@ Docs: https://docs.openclaw.ai - Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns. - Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns. - Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon. +- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda. - Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88. ## 2026.5.20 diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts index ecb89a48e3e..1a3b930394d 100644 --- a/src/tui/components/chat-log.test.ts +++ b/src/tui/components/chat-log.test.ts @@ -183,6 +183,33 @@ describe("ChatLog", () => { expect(chatLog.countPendingUsers()).toBe(0); }); + it("dismisses a pending system notice by runId", () => { + const chatLog = new ChatLog(40); + + chatLog.addPendingSystem("run-1", "taking longer than expected"); + let rendered = chatLog.render(120).join("\n"); + expect(rendered).toContain("taking longer than expected"); + + const dismissed = chatLog.dismissPendingSystem("run-1"); + expect(dismissed).toBe(true); + + rendered = chatLog.render(120).join("\n"); + expect(rendered).not.toContain("taking longer than expected"); + expect(chatLog.dismissPendingSystem("run-1")).toBe(false); + }); + + it("replaces an existing pending system notice for the same runId", () => { + const chatLog = new ChatLog(40); + + chatLog.addPendingSystem("run-1", "first notice"); + chatLog.addPendingSystem("run-1", "second notice"); + + const rendered = chatLog.render(120).join("\n"); + expect(rendered).not.toContain("first notice"); + expect(rendered).toContain("second notice"); + expect(chatLog.children.length).toBe(1); + }); + it("does not hide a new repeated prompt when only older history matches", () => { const chatLog = new ChatLog(40); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 7fcfa0d94d7..1c3f29519fe 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -27,6 +27,7 @@ export class ChatLog extends Container { createdAt: number; } >(); + private pendingSystemNotices = new Map(); private btwMessage: BtwInlineMessage | null = null; private toolsExpanded = false; private repeatableSystemMessage: RepeatableSystemMessage | null = null; @@ -52,6 +53,11 @@ export class ChatLog extends Container { this.pendingUsers.delete(runId); } } + for (const [runId, entry] of this.pendingSystemNotices.entries()) { + if (entry === component) { + this.pendingSystemNotices.delete(runId); + } + } if (this.btwMessage === component) { this.btwMessage = null; } @@ -85,6 +91,7 @@ export class ChatLog extends Container { this.clear(); this.toolById.clear(); this.streamingRuns.clear(); + this.pendingSystemNotices.clear(); this.btwMessage = null; this.repeatableSystemMessage = null; if (!opts?.preservePendingUsers) { @@ -142,6 +149,26 @@ export class ChatLog extends Container { this.repeatableSystemMessage = opts?.coalesceConsecutive ? message : null; } + addPendingSystem(runId: string, text: string) { + const existing = this.pendingSystemNotices.get(runId); + if (existing) { + this.removeChild(existing); + } + const message = this.createSystemMessage(text); + this.pendingSystemNotices.set(runId, message.component); + this.append(message.component); + } + + dismissPendingSystem(runId: string) { + const existing = this.pendingSystemNotices.get(runId); + if (!existing) { + return false; + } + this.removeChild(existing); + this.pendingSystemNotices.delete(runId); + return true; + } + addUser(text: string) { this.appendNonSystem(new UserMessageComponent(text)); } diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 0679512743b..fe90e23909c 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -8,6 +8,8 @@ type HandlerChatLog = { startTool: (...args: unknown[]) => void; updateToolResult: (...args: unknown[]) => void; addSystem: (...args: unknown[]) => void; + addPendingSystem: (...args: unknown[]) => void; + dismissPendingSystem: (...args: unknown[]) => void; updateAssistant: (...args: unknown[]) => void; finalizeAssistant: (...args: unknown[]) => void; dropAssistant: (...args: unknown[]) => void; @@ -21,6 +23,8 @@ type MockChatLog = { startTool: MockFn; updateToolResult: MockFn; addSystem: MockFn; + addPendingSystem: MockFn; + dismissPendingSystem: MockFn; updateAssistant: MockFn; finalizeAssistant: MockFn; dropAssistant: MockFn; @@ -36,6 +40,8 @@ function createMockChatLog(): MockChatLog & HandlerChatLog { startTool: vi.fn(), updateToolResult: vi.fn(), addSystem: vi.fn(), + addPendingSystem: vi.fn(), + dismissPendingSystem: vi.fn(), updateAssistant: vi.fn(), finalizeAssistant: vi.fn(), dropAssistant: vi.fn(), @@ -1178,7 +1184,7 @@ describe("tui-event-handlers: streaming watchdog", () => { expect(setActivityStatus).toHaveBeenLastCalledWith("idle"); expect(state.activeChatRunId).toBeNull(); - expect(chatLog.addSystem).toHaveBeenCalledWith(expectedTimeoutMessage); + expect(chatLog.addPendingSystem).toHaveBeenCalledWith("run-stuck", expectedTimeoutMessage); handlers.dispose?.(); }); @@ -1384,7 +1390,7 @@ describe("tui-event-handlers: streaming watchdog", () => { expect(setActivityStatus).toHaveBeenLastCalledWith("idle"); expect(state.activeChatRunId).toBeNull(); expect(loadHistory).toHaveBeenCalledTimes(1); - expect(chatLog.addSystem).not.toHaveBeenCalledWith(expectedTimeoutMessage); + expect(chatLog.addPendingSystem).not.toHaveBeenCalled(); handlers.dispose?.(); }); @@ -1410,8 +1416,8 @@ describe("tui-event-handlers: streaming watchdog", () => { vi.advanceTimersByTime(10_000); const statusCalls = setActivityStatus.mock.calls.map((c) => c[0]); - expect(statusCalls.reduce((count, s) => count + (s === "idle" ? 1 : 0), 0)).toBe(1); - expect(chatLog.addSystem).not.toHaveBeenCalledWith(expectedTimeoutMessage); + expect(statusCalls.filter((s) => s === "idle").length).toBe(1); + expect(chatLog.addPendingSystem).not.toHaveBeenCalled(); expect(state.activeChatRunId).toBeNull(); handlers.dispose?.(); @@ -1432,7 +1438,7 @@ describe("tui-event-handlers: streaming watchdog", () => { vi.advanceTimersByTime(60_000); expect(setActivityStatus).not.toHaveBeenCalledWith("idle"); - expect(chatLog.addSystem).not.toHaveBeenCalled(); + expect(chatLog.addPendingSystem).not.toHaveBeenCalled(); expect(state.activeChatRunId).toBe("run-no-watchdog"); handlers.dispose?.(); @@ -1474,7 +1480,7 @@ describe("tui-event-handlers: streaming watchdog", () => { expect(setActivityStatus).toHaveBeenLastCalledWith("idle"); expect(state.activeChatRunId).toBeNull(); - expect(chatLog.addSystem).toHaveBeenCalledTimes(2); + expect(chatLog.addPendingSystem).toHaveBeenCalledTimes(2); handlers.dispose?.(); }); @@ -1495,6 +1501,60 @@ describe("tui-event-handlers: streaming watchdog", () => { vi.advanceTimersByTime(10_000); expect(setActivityStatus).not.toHaveBeenCalledWith("idle"); - expect(chatLog.addSystem).not.toHaveBeenCalled(); + expect(chatLog.addPendingSystem).not.toHaveBeenCalled(); + }); + + it("dismisses the watchdog notice when a delta arrives after the watchdog fires", () => { + const { state, chatLog, handlers } = createHarness({ + streamingWatchdogMs: 5_000, + }); + + handlers.handleChatEvent({ + runId: "run-late", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "starting" }, + } satisfies ChatEvent); + + vi.advanceTimersByTime(5_001); + expect(chatLog.addPendingSystem).toHaveBeenCalledWith("run-late", expectedTimeoutMessage); + + handlers.handleChatEvent({ + runId: "run-late", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "actually here" }, + } satisfies ChatEvent); + + expect(chatLog.dismissPendingSystem).toHaveBeenCalledWith("run-late"); + + handlers.dispose?.(); + }); + + it("dismisses the watchdog notice when the final arrives after the watchdog fires", () => { + const { state, chatLog, handlers } = createHarness({ + streamingWatchdogMs: 5_000, + }); + + handlers.handleChatEvent({ + runId: "run-final-late", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "starting" }, + } satisfies ChatEvent); + + vi.advanceTimersByTime(5_001); + expect(chatLog.addPendingSystem).toHaveBeenCalledWith("run-final-late", expectedTimeoutMessage); + + handlers.handleChatEvent({ + runId: "run-final-late", + sessionKey: state.currentSessionKey, + state: "final", + message: { content: [{ type: "text", text: "done" }], stopReason: "stop" }, + } satisfies ChatEvent); + + expect(chatLog.dismissPendingSystem).toHaveBeenCalledWith("run-final-late"); + + handlers.dispose?.(); }); }); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index aa9a3699c58..8daef29bb53 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -14,6 +14,8 @@ type EventHandlerChatLog = { options?: { partial?: boolean; isError?: boolean }, ) => void; addSystem: (text: string) => void; + addPendingSystem: (runId: string, text: string) => void; + dismissPendingSystem: (runId: string) => void; updateAssistant: (text: string, runId: string) => void; finalizeAssistant: (text: string, runId: string) => void; dropAssistant: (runId: string) => void; @@ -132,7 +134,7 @@ export function createEventHandlers(context: EventHandlerContext) { return; } flushPendingHistoryRefreshIfIdle(); - chatLog.addSystem(STREAMING_WATCHDOG_USER_MESSAGE); + chatLog.addPendingSystem(runId, STREAMING_WATCHDOG_USER_MESSAGE); tui.requestRender(); }, streamingWatchdogMs); const maybeUnref = (streamingWatchdogTimer as { unref?: () => void }).unref; @@ -398,6 +400,7 @@ export function createEventHandlers(context: EventHandlerContext) { if (reconnectPendingRunId === evt.runId) { reconnectPendingRunId = null; } + chatLog.dismissPendingSystem(evt.runId); noteSessionRun(evt.runId); if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) { state.activeChatRunId = evt.runId;