fix(tui): recover stale streaming status after unbound final (#73749)

* fix(tui): clear stale streaming after unbound final events

* fix(clownfish): address review for ghcrawl-156749-autonomous-smoke (1)

* fix(tui): address stale streaming review
This commit is contained in:
Vincent Koc
2026-04-29 04:12:25 -07:00
committed by GitHub
parent 77a5d82e64
commit 6d7a77dcf9
3 changed files with 115 additions and 9 deletions

View File

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

View File

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

View File

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