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 <noreply@anthropic.com>

* fix(tui): preserve concurrent injected run activity

---------

Co-authored-by: zengwen <zeng_wen@foxmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
ZengWen-DT
2026-06-16 08:27:59 +08:00
committed by GitHub
parent 03e3ef86af
commit 01acb34bdb
3 changed files with 160 additions and 15 deletions

View File

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

View File

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

View File

@@ -51,6 +51,11 @@ export type AgentEvent = {
runId: string;
stream: string;
data?: Record<string, unknown>;
// 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";