diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ff3e006a5..224c18bfa80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest. - ACP/commands: accept forwarded ACP timeout config controls in the OpenClaw bridge, treat unsupported discard-close controls as recoverable cleanup, and restore native `/verbose full` plus no-arg status behavior, so Discord command menus and nested ACP turns no longer fail on supported session controls. Thanks @vincentkoc. +- TUI/status: clear stale `streaming` footer state when a final event arrives after the active run was already cleared and no tracked runs remain, while preserving concurrent-run ownership and inactive local `/btw` terminal handling. Fixes #64825; carries forward #64842, #64843, #64847, and #64862. Thanks @briandevans and @Yanhu007. - Channels/Discord: fail startup closed when Discord cannot resolve the bot's own identity and keep mention gating active when only configured mention patterns can detect mentions, so the provider no longer continues with a missing bot id. Fixes #42219; carries forward #46856 and #49218. Thanks @education-01 and @BenediktSchackenberg. - Channels/Discord: split long CJK replies at punctuation and code-point-safe fallback boundaries so Discord chunking stays readable without corrupting astral characters. Fixes #38597; repairs #71384. Thanks @p3nchan. - Browser/gateway: ignore Playwright dialog-close races from `Page.handleJavaScriptDialog` so browser automation no longer crashes the Gateway when a dialog disappears before Playwright accepts it. (#40067) Thanks @randyjtw. diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 03b25941932..3d51640f09e 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -306,6 +306,46 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); }); + it("clears stale streaming for a local BTW empty final without hiding the result", () => { + const { + state, + btw, + loadHistory, + setActivityStatus, + noteLocalBtwRunId, + handleBtwEvent, + handleChatEvent, + } = createHandlersHarness({ + state: { activeChatRunId: null, activityStatus: "streaming" }, + }); + + noteLocalBtwRunId("run-btw"); + handleBtwEvent({ + kind: "btw", + runId: "run-btw", + sessionKey: state.currentSessionKey, + question: "what changed?", + text: "nothing important", + } satisfies BtwEvent); + setActivityStatus.mockClear(); + + handleChatEvent({ + runId: "run-btw", + sessionKey: state.currentSessionKey, + state: "final", + } satisfies ChatEvent); + + expect(state.activeChatRunId).toBeNull(); + expect(state.activityStatus).toBe("idle"); + expect(setActivityStatus).toHaveBeenCalledWith("idle"); + expect(loadHistory).not.toHaveBeenCalled(); + expect(btw.showResult).toHaveBeenCalledWith({ + question: "what changed?", + text: "nothing important", + isError: undefined, + }); + }); + it("does not cross-match canonical session keys from different agents", () => { const { chatLog, handleChatEvent } = createHandlersHarness({ state: { @@ -548,6 +588,48 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(setActivityStatus).toHaveBeenCalledWith("idle"); }); + it("clears stale streaming when a duplicate final arrives after inactive /btw terminal cleanup", () => { + const { state, setActivityStatus, noteLocalBtwRunId, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: null, activityStatus: "streaming" }, + }); + + handleChatEvent({ + runId: "run-finalized", + sessionKey: state.currentSessionKey, + state: "final", + message: { content: [{ type: "text", text: "done" }] }, + }); + + noteLocalBtwRunId("run-btw-error"); + handleChatEvent({ + runId: "run-btw-error", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "background status update" }, + }); + handleChatEvent({ + runId: "run-btw-error", + sessionKey: state.currentSessionKey, + state: "error", + errorMessage: "background failure", + }); + + expect(state.activeChatRunId).toBeNull(); + expect(state.activityStatus).toBe("streaming"); + setActivityStatus.mockClear(); + + handleChatEvent({ + runId: "run-finalized", + sessionKey: state.currentSessionKey, + state: "final", + message: { content: [{ type: "text", text: "done" }] }, + }); + + expect(state.activeChatRunId).toBeNull(); + expect(state.activityStatus).toBe("idle"); + expect(setActivityStatus).toHaveBeenCalledWith("idle"); + }); + it("flushes deferred history reload after stale streaming clear makes the TUI idle", () => { const { state, loadHistory, noteLocalRunId, setActivityStatus, handleChatEvent } = createHandlersHarness({ @@ -589,6 +671,31 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(setActivityStatus).not.toHaveBeenCalledWith("error"); }); + it("does not clear global streaming for inactive local /btw aborted or error events", () => { + const { state, setActivityStatus, noteLocalBtwRunId, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: null, activityStatus: "streaming" }, + }); + + for (const terminalState of ["aborted", "error"] as const) { + const runId = `run-btw-${terminalState}`; + state.activeChatRunId = null; + state.activityStatus = "streaming"; + setActivityStatus.mockClear(); + noteLocalBtwRunId(runId); + + handleChatEvent({ + runId, + sessionKey: state.currentSessionKey, + state: terminalState, + errorMessage: terminalState === "error" ? "boom" : undefined, + }); + + expect(state.activeChatRunId).toBeNull(); + expect(state.activityStatus).toBe("streaming"); + expect(setActivityStatus).not.toHaveBeenCalled(); + } + }); + it("does not force idle for an inactive final while another tracked run is active", () => { const { state, setActivityStatus, handleChatEvent } = createConcurrentRunHarness("partial"); state.activityStatus = "streaming"; diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index f6413df1ef6..b4e5b945755 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -196,14 +196,11 @@ export function createEventHandlers(context: EventHandlerContext) { } }; - const clearStaleStreamingRunIfNoTrackedRunRemains = () => { + const clearStaleStreamingIfNoTrackedRunRemains = () => { const activeRunId = state.activeChatRunId; - if ( - !activeRunId || - sessionRuns.has(activeRunId) || - sessionRuns.size > 0 || - state.activityStatus !== "streaming" - ) { + // A missing active run is the recovery case; only tracked active runs block cleanup. + const activeRunIsStillTracked = activeRunId ? sessionRuns.has(activeRunId) : false; + if (state.activityStatus !== "streaming" || activeRunIsStillTracked || sessionRuns.size > 0) { return; } state.activeChatRunId = null; @@ -228,7 +225,7 @@ export function createEventHandlers(context: EventHandlerContext) { if (streamingWatchdogRunId === params.runId) { clearStreamingWatchdog(); } - clearStaleStreamingRunIfNoTrackedRunRemains(); + clearStaleStreamingIfNoTrackedRunRemains(); } void refreshSessionInfo?.(); }; @@ -249,7 +246,6 @@ export function createEventHandlers(context: EventHandlerContext) { if (streamingWatchdogRunId === params.runId) { clearStreamingWatchdog(); } - clearStaleStreamingRunIfNoTrackedRunRemains(); } void refreshSessionInfo?.(); }; @@ -324,6 +320,7 @@ export function createEventHandlers(context: EventHandlerContext) { return; } if (evt.state === "final") { + clearStaleStreamingIfNoTrackedRunRemains(); return; } } @@ -355,6 +352,7 @@ export function createEventHandlers(context: EventHandlerContext) { if (!evt.message && isLocalBtwRun) { forgetLocalBtwRunId?.(evt.runId); noteFinalizedRun(evt.runId); + clearStaleStreamingIfNoTrackedRunRemains(); tui.requestRender(); return; }