From 01acb34bdbb6fdd58bb178c06295edb0c816efb6 Mon Sep 17 00:00:00 2001 From: ZengWen-DT Date: Tue, 16 Jun 2026 08:27:59 +0800 Subject: [PATCH] fix(tui): show activity indicator for system-injected runs (#93427) * fix(tui): show activity indicator for system-injected runs System-injected runs (bridge-notify, webhook, cron) never go through the TUI submit path, so no active/pending run id exists when their lifecycle "start" event arrives. handleAgentEvent dropped events for untracked runs, leaving the status bar idle until the response landed. Adopt an untracked lifecycle "start" for the current session (lifecycle events always carry sessionKey) so the activity indicator shows work is happening, mirroring how chat deltas adopt runs in handleChatEvent. Local side-question (btw) runs never claim the active slot. Closes #51825 Co-Authored-By: Claude Opus 4.8 * fix(tui): preserve concurrent injected run activity --------- Co-authored-by: zengwen Co-authored-by: Claude Opus 4.8 Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> --- src/tui/tui-event-handlers.test.ts | 86 ++++++++++++++++++++++++++++++ src/tui/tui-event-handlers.ts | 84 +++++++++++++++++++++++------ src/tui/tui-types.ts | 5 ++ 3 files changed, 160 insertions(+), 15 deletions(-) diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 6383c226207..3c07bef0df5 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -220,6 +220,92 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(tui.requestRender).toHaveBeenCalledTimes(1); }); + it("shows running for a system-injected run that never went through submit", () => { + const { state, tui, setActivityStatus, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, + }); + + handleAgentEvent({ + runId: "run-bridge", + stream: "lifecycle", + sessionKey: state.currentSessionKey, + data: { phase: "start" }, + }); + + expect(setActivityStatus).toHaveBeenCalledWith("running"); + expect(state.activeChatRunId).toBe("run-bridge"); + expect(tui.requestRender).toHaveBeenCalled(); + }); + + it("does not adopt a system-injected lifecycle start from another session", () => { + const { state, tui, setActivityStatus, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, + }); + + handleAgentEvent({ + runId: "run-other", + stream: "lifecycle", + sessionKey: "agent:other:other", + data: { phase: "start" }, + }); + + expect(setActivityStatus).not.toHaveBeenCalled(); + expect(state.activeChatRunId).toBeNull(); + expect(tui.requestRender).not.toHaveBeenCalled(); + }); + + it("does not let a system-injected run steal a concurrent active run", () => { + const { state, setActivityStatus, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: "run-user" }, + }); + + handleAgentEvent({ + runId: "run-bridge", + stream: "lifecycle", + sessionKey: state.currentSessionKey, + data: { phase: "start" }, + }); + handleAgentEvent({ + runId: "run-bridge", + stream: "lifecycle", + sessionKey: state.currentSessionKey, + data: { phase: "finishing" }, + }); + handleAgentEvent({ + runId: "run-bridge", + stream: "lifecycle", + sessionKey: state.currentSessionKey, + data: { phase: "end" }, + }); + + expect(state.activeChatRunId).toBe("run-user"); + expect(setActivityStatus).not.toHaveBeenCalledWith("running"); + expect(setActivityStatus).not.toHaveBeenCalledWith("finishing context"); + expect(setActivityStatus).not.toHaveBeenCalledWith("idle"); + }); + + it("promotes a remaining system-injected run when the active run finishes", () => { + const { state, setActivityStatus, handleAgentEvent, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: "run-user" }, + }); + + handleAgentEvent({ + runId: "run-bridge", + stream: "lifecycle", + sessionKey: state.currentSessionKey, + data: { phase: "start" }, + }); + handleChatEvent({ + runId: "run-user", + sessionKey: state.currentSessionKey, + state: "final", + message: { content: [{ type: "text", text: "done" }], stopReason: "stop" }, + }); + + expect(state.activeChatRunId).toBe("run-bridge"); + expect(setActivityStatus).toHaveBeenLastCalledWith("running"); + }); + it("renders terminal lifecycle errors after retry grace and clears the active run", () => { vi.useFakeTimers(); const { state, chatLog, tui, setActivityStatus, loadHistory, handleAgentEvent } = diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 0016d79be7d..8dec89aaf90 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -301,6 +301,30 @@ export function createEventHandlers(context: EventHandlerContext) { } }; + const promoteMostRecentSessionRun = (): boolean => { + if (state.activeChatRunId || sessionRuns.size === 0) { + return false; + } + let nextRunId: string | undefined; + let nextSeenAt = -1; + for (const [runId, seenAt] of sessionRuns) { + if (seenAt > nextSeenAt) { + nextRunId = runId; + nextSeenAt = seenAt; + } + } + if (!nextRunId) { + return false; + } + // A concurrent run can outlive the active run. Keep the activity owner on + // remaining work so terminal cleanup cannot incorrectly return the TUI idle. + state.activeChatRunId = nextRunId; + clearStreamingWatchdog(); + setActivityStatus("running"); + armStreamingWatchdog(nextRunId); + return true; + }; + const clearStaleStreamingIfNoTrackedRunRemains = () => { const activeRunId = state.activeChatRunId; // A missing active run is the recovery case; only tracked active runs block cleanup. @@ -344,15 +368,18 @@ export function createEventHandlers(context: EventHandlerContext) { }) => { noteFinalizedRun(params.runId, { displayedFinal: params.displayedFinal }); clearActiveRunIfMatch(params.runId); + const promotedRemainingRun = promoteMostRecentSessionRun(); flushPendingHistoryRefreshIfIdle(); - if (params.wasActiveRun) { - setActivityStatus(params.status); - clearStreamingWatchdog(); - } else { - if (streamingWatchdogRunId === params.runId) { + if (!promotedRemainingRun) { + if (params.wasActiveRun) { + setActivityStatus(params.status); clearStreamingWatchdog(); + } else { + if (streamingWatchdogRunId === params.runId) { + clearStreamingWatchdog(); + } + clearStaleStreamingIfNoTrackedRunRemains(); } - clearStaleStreamingIfNoTrackedRunRemains(); } void refreshSessionInfo?.(); }; @@ -367,12 +394,15 @@ export function createEventHandlers(context: EventHandlerContext) { streamAssembler.drop(params.runId); sessionRuns.delete(params.runId); clearActiveRunIfMatch(params.runId); + const promotedRemainingRun = promoteMostRecentSessionRun(); flushPendingHistoryRefreshIfIdle(); - if (params.wasActiveRun) { - setActivityStatus(params.status); - clearStreamingWatchdog(); - } else if (streamingWatchdogRunId === params.runId) { - clearStreamingWatchdog(); + if (!promotedRemainingRun) { + if (params.wasActiveRun) { + setActivityStatus(params.status); + clearStreamingWatchdog(); + } else if (streamingWatchdogRunId === params.runId) { + clearStreamingWatchdog(); + } } void refreshSessionInfo?.(); }; @@ -419,10 +449,7 @@ export function createEventHandlers(context: EventHandlerContext) { const hasConcurrentActiveRun = (runId: string) => { const activeRunId = state.activeChatRunId; - if (!activeRunId || activeRunId === runId) { - return false; - } - return sessionRuns.has(activeRunId); + return Boolean(activeRunId && activeRunId !== runId); }; const maybeRefreshHistoryForRun = ( @@ -699,6 +726,33 @@ export function createEventHandlers(context: EventHandlerContext) { } const evt = payload as AgentEvent; syncSessionKey(); + // System-injected runs (bridge-notify, webhook, cron) never go through the + // TUI submit path, so no active/pending run id exists when their lifecycle + // "start" arrives — leaving the status bar idle until the response lands. + // Adopt such a run for the current session (lifecycle events always carry + // sessionKey) so the activity indicator shows work is happening, mirroring + // how chat deltas adopt runs in handleChatEvent. Only claim the active slot + // when none is held, so a concurrent user run keeps the indicator. + const isUntrackedRun = + evt.runId !== state.activeChatRunId && + evt.runId !== state.pendingChatRunId && + !sessionRuns.has(evt.runId) && + !finalizedRuns.has(evt.runId); + if ( + isUntrackedRun && + evt.stream === "lifecycle" && + asString(evt.data?.phase, "") === "start" && + !(isLocalBtwRunId?.(evt.runId) ?? false) && + isSameSessionKey(evt.sessionKey, state.currentSessionKey) && + isMatchingGlobalAgentEvent(evt.sessionKey, evt.agentId) + ) { + noteSessionRun(evt.runId); + // Mirror handleChatEvent: side-question (btw) runs never claim the active + // slot, so a concurrent btw run cannot hijack the main activity indicator. + if (!state.activeChatRunId) { + state.activeChatRunId = evt.runId; + } + } // Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the // active chat run id, not the session id. Tool results can arrive after the chat // final event, so accept finalized runs for tool updates. diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 9ec02054eb8..be8af7f7ca1 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -51,6 +51,11 @@ export type AgentEvent = { runId: string; stream: string; data?: Record; + // Stamped by the gateway on every emitted payload (see infra/agent-events.ts). + // Lifecycle events always carry sessionKey, letting the TUI adopt + // system-injected runs that never went through the local submit path. + sessionKey?: string; + agentId?: string; }; export type ResponseUsageMode = "on" | "off" | "tokens" | "full";