fix(tui): dismiss watchdog notice when response actually arrives (#77375)

* 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 <steipete@gmail.com>
This commit is contained in:
Dallin Romney
2026-05-22 12:02:36 -07:00
committed by GitHub
parent d756e1c500
commit b741ddb66f
5 changed files with 126 additions and 8 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -27,6 +27,7 @@ export class ChatLog extends Container {
createdAt: number;
}
>();
private pendingSystemNotices = new Map<string, Container>();
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));
}

View File

@@ -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?.();
});
});

View File

@@ -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;