Skip to content

Commit d7e67b4

Browse files
authored
fix(tui): clear stale streaming after orphaned finals (#72389)
* fix(tui): clear stale streaming after orphaned finals * fix(tui): clear stale streaming after orphaned finals * fix(tui): clear stale streaming after orphaned finals
1 parent db7cab4 commit d7e67b4

3 files changed

Lines changed: 152 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ Docs: https://docs.openclaw.ai
428428
- Gateway health: preserve live runtime-backed channel/account state in `gateway.health` snapshots and cached refreshes while keeping raw probe payloads on sensitive/admin paths only. (#39921, #42586, #46527, #52770, #42543) Thanks @FAL1989, @rstar327, @0xble, and @ajayr.
429429
- Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67.
430430
- Telegram/agents: hide raw failed write/edit warning messages in Telegram when the assistant already explicitly acknowledges the failed action, while keeping warnings when the reply claims success or omits the failure; #39406 remains the broader configurable delivery-policy follow-up. Fixes #51065; covers #39631. Thanks @Bartok9 and @Bortlesboat.
431+
- TUI: clear stale streaming status when an orphaned final event or watchdog reset leaves no tracked active run, flushing deferred local history refreshes without surfacing inactive-run failures. Fixes #64825; carries forward #52745. Thanks @lyksdu.
431432
- Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim.
432433
- Logging: add top-level `hostname`, flattened `message`, and available `agent_id`, `session_id`, and `channel` fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez.
433434
- ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924.

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

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,79 @@ describe("tui-event-handlers: handleAgentEvent", () => {
532532
expect(chatLog.updateAssistant).toHaveBeenLastCalledWith("continued", "run-active");
533533
});
534534

535+
it("clears stale streaming when an orphan final arrives and no tracked run remains", () => {
536+
const { state, setActivityStatus, handleChatEvent } = createHandlersHarness({
537+
state: { activeChatRunId: "run-stale", activityStatus: "streaming" },
538+
});
539+
540+
handleChatEvent({
541+
runId: "run-orphan",
542+
sessionKey: state.currentSessionKey,
543+
state: "final",
544+
message: { content: [{ type: "text", text: "done" }] },
545+
});
546+
547+
expect(state.activeChatRunId).toBeNull();
548+
expect(setActivityStatus).toHaveBeenCalledWith("idle");
549+
});
550+
551+
it("flushes deferred history reload after stale streaming clear makes the TUI idle", () => {
552+
const { state, loadHistory, noteLocalRunId, setActivityStatus, handleChatEvent } =
553+
createHandlersHarness({
554+
state: { activeChatRunId: "run-stale", activityStatus: "streaming" },
555+
});
556+
557+
noteLocalRunId("run-local-empty");
558+
loadHistory.mockImplementation(() => {
559+
expect(state.activeChatRunId).toBeNull();
560+
expect(state.activityStatus).toBe("idle");
561+
});
562+
563+
handleChatEvent({
564+
runId: "run-local-empty",
565+
sessionKey: state.currentSessionKey,
566+
state: "final",
567+
});
568+
569+
expect(state.activeChatRunId).toBeNull();
570+
expect(state.activityStatus).toBe("idle");
571+
expect(setActivityStatus).toHaveBeenCalledWith("idle");
572+
expect(loadHistory).toHaveBeenCalledTimes(1);
573+
});
574+
575+
it("does not surface inactive orphan final failures as the global status", () => {
576+
const { state, setActivityStatus, handleChatEvent } = createHandlersHarness({
577+
state: { activeChatRunId: "run-stale", activityStatus: "streaming" },
578+
});
579+
580+
handleChatEvent({
581+
runId: "run-orphan-error",
582+
sessionKey: state.currentSessionKey,
583+
state: "final",
584+
message: { content: [{ type: "text", text: "failed" }], stopReason: "error" },
585+
});
586+
587+
expect(state.activeChatRunId).toBeNull();
588+
expect(setActivityStatus).toHaveBeenCalledWith("idle");
589+
expect(setActivityStatus).not.toHaveBeenCalledWith("error");
590+
});
591+
592+
it("does not force idle for an inactive final while another tracked run is active", () => {
593+
const { state, setActivityStatus, handleChatEvent } = createConcurrentRunHarness("partial");
594+
state.activityStatus = "streaming";
595+
setActivityStatus.mockClear();
596+
597+
handleChatEvent({
598+
runId: "run-other",
599+
sessionKey: state.currentSessionKey,
600+
state: "final",
601+
message: { content: [{ type: "text", text: "other final" }] },
602+
});
603+
604+
expect(state.activeChatRunId).toBe("run-active");
605+
expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
606+
});
607+
535608
it("suppresses non-local empty final placeholders during concurrent runs", () => {
536609
const { state, chatLog, loadHistory, handleChatEvent } =
537610
createConcurrentRunHarness("local stream");
@@ -715,15 +788,24 @@ describe("tui-event-handlers: streaming watchdog", () => {
715788
const btw = createMockBtwPresenter();
716789
const tui = { requestRender: vi.fn() } as unknown as MockTui & HandlerTui;
717790
const setActivityStatus = vi.fn();
791+
const loadHistory = vi.fn();
792+
const localRunIds = new Set<string>();
793+
const noteLocalRunId = (runId: string) => {
794+
localRunIds.add(runId);
795+
};
718796
const handlers = createEventHandlers({
719797
chatLog,
720798
btw,
721799
tui,
722800
state,
723801
setActivityStatus,
802+
loadHistory,
803+
noteLocalRunId,
804+
isLocalRunId: localRunIds.has.bind(localRunIds),
805+
forgetLocalRunId: localRunIds.delete.bind(localRunIds),
724806
streamingWatchdogMs: options?.streamingWatchdogMs,
725807
});
726-
return { state, chatLog, tui, setActivityStatus, handlers };
808+
return { state, chatLog, tui, setActivityStatus, loadHistory, noteLocalRunId, handlers };
727809
};
728810

729811
it("resets activityStatus to idle when no stream delta arrives for the watchdog window", () => {
@@ -750,6 +832,37 @@ describe("tui-event-handlers: streaming watchdog", () => {
750832
handlers.dispose?.();
751833
});
752834

835+
it("flushes a deferred history reload when the watchdog clears the active run", () => {
836+
const { state, loadHistory, noteLocalRunId, setActivityStatus, handlers } = createHarness({
837+
streamingWatchdogMs: 5_000,
838+
});
839+
840+
handlers.handleChatEvent({
841+
runId: "run-stuck",
842+
sessionKey: state.currentSessionKey,
843+
state: "delta",
844+
message: { content: "hello" },
845+
} satisfies ChatEvent);
846+
847+
noteLocalRunId("run-local-empty");
848+
handlers.handleChatEvent({
849+
runId: "run-local-empty",
850+
sessionKey: state.currentSessionKey,
851+
state: "final",
852+
} satisfies ChatEvent);
853+
854+
expect(loadHistory).not.toHaveBeenCalled();
855+
856+
vi.advanceTimersByTime(5_001);
857+
858+
expect(state.activeChatRunId).toBeNull();
859+
expect(state.activityStatus).toBe("idle");
860+
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
861+
expect(loadHistory).toHaveBeenCalledTimes(1);
862+
863+
handlers.dispose?.();
864+
});
865+
753866
it("refreshes the watchdog window on each new stream delta", () => {
754867
const { state, setActivityStatus, handlers } = createHarness({
755868
streamingWatchdogMs: 5_000,

src/tui/tui-event-handlers.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ export function createEventHandlers(context: EventHandlerContext) {
8282
let streamingWatchdogTimer: ReturnType<typeof setTimeout> | null = null;
8383
let streamingWatchdogRunId: string | null = null;
8484

85+
const flushPendingHistoryRefreshIfIdle = () => {
86+
if (!pendingHistoryRefresh || state.activeChatRunId) {
87+
return;
88+
}
89+
pendingHistoryRefresh = false;
90+
void loadHistory?.();
91+
};
92+
8593
const clearStreamingWatchdog = () => {
8694
if (streamingWatchdogTimer) {
8795
clearTimeout(streamingWatchdogTimer);
@@ -105,7 +113,9 @@ export function createEventHandlers(context: EventHandlerContext) {
105113
}
106114
streamingWatchdogRunId = null;
107115
state.activeChatRunId = null;
116+
state.activityStatus = "idle";
108117
setActivityStatus("idle");
118+
flushPendingHistoryRefreshIfIdle();
109119
chatLog.addSystem(
110120
`streaming watchdog: no stream updates for ${Math.round(
111121
streamingWatchdogMs / 1000,
@@ -158,14 +168,6 @@ export function createEventHandlers(context: EventHandlerContext) {
158168
clearStreamingWatchdog();
159169
};
160170

161-
const flushPendingHistoryRefreshIfIdle = () => {
162-
if (!pendingHistoryRefresh || state.activeChatRunId) {
163-
return;
164-
}
165-
pendingHistoryRefresh = false;
166-
void loadHistory?.();
167-
};
168-
169171
const resolveAuthErrorHint = (errorMessage: string): string | undefined => {
170172
if (!localMode || !isAuthErrorMessage(errorMessage)) {
171173
return undefined;
@@ -194,6 +196,23 @@ export function createEventHandlers(context: EventHandlerContext) {
194196
}
195197
};
196198

199+
const clearStaleStreamingRunIfNoTrackedRunRemains = () => {
200+
const activeRunId = state.activeChatRunId;
201+
if (
202+
!activeRunId ||
203+
sessionRuns.has(activeRunId) ||
204+
sessionRuns.size > 0 ||
205+
state.activityStatus !== "streaming"
206+
) {
207+
return;
208+
}
209+
state.activeChatRunId = null;
210+
state.activityStatus = "idle";
211+
setActivityStatus("idle");
212+
clearStreamingWatchdog();
213+
flushPendingHistoryRefreshIfIdle();
214+
};
215+
197216
const finalizeRun = (params: {
198217
runId: string;
199218
wasActiveRun: boolean;
@@ -205,8 +224,11 @@ export function createEventHandlers(context: EventHandlerContext) {
205224
if (params.wasActiveRun) {
206225
setActivityStatus(params.status);
207226
clearStreamingWatchdog();
208-
} else if (streamingWatchdogRunId === params.runId) {
209-
clearStreamingWatchdog();
227+
} else {
228+
if (streamingWatchdogRunId === params.runId) {
229+
clearStreamingWatchdog();
230+
}
231+
clearStaleStreamingRunIfNoTrackedRunRemains();
210232
}
211233
void refreshSessionInfo?.();
212234
};
@@ -223,8 +245,11 @@ export function createEventHandlers(context: EventHandlerContext) {
223245
if (params.wasActiveRun) {
224246
setActivityStatus(params.status);
225247
clearStreamingWatchdog();
226-
} else if (streamingWatchdogRunId === params.runId) {
227-
clearStreamingWatchdog();
248+
} else {
249+
if (streamingWatchdogRunId === params.runId) {
250+
clearStreamingWatchdog();
251+
}
252+
clearStaleStreamingRunIfNoTrackedRunRemains();
228253
}
229254
void refreshSessionInfo?.();
230255
};

0 commit comments

Comments
 (0)