fix(webui): stop passive observer timer from dropping promptStatus mid-turn (#9487)

Two changes:

1. Remove setPromptStatus("idle") from the passive observer timer callback.
   The timer still dispatches assistant.done to finish stale streaming blocks,
   but promptStatus is now only changed by terminal events and authoritative
   hasActivePrompt snapshots. Long tool calls produce >3s silent gaps, so the
   old callback dropped the conversation loading indicator mid-turn.

2. Preserve current promptStatus at episode start for observer panes.
   During PATH A reconnects (same session client, Last-Event-ID resume),
   setPromptStatus(hasSessionActivePrompt() ? "streaming" : "idle") would
   reset an observer pane to idle even though the turn is still running.
   Now the episode start keeps the current non-idle state; replay events
   either confirm it (turn still running) or a terminal event settles it
   (turn ended).
This commit is contained in:
jinjing.zzj 2026-08-21 17:11:45 +08:00
parent 9f2342d323
commit fb870e9ca3
2 changed files with 110 additions and 2 deletions

View file

@ -7388,6 +7388,113 @@ describe('DaemonSessionProvider', () => {
}
});
it('keeps observer pane loading across silent gaps during long tool calls (#9487)', async () => {
vi.useFakeTimers();
try {
// An observer pane (different client submitted the turn) sees the turn
// start on the event stream, then a long tool call produces a >3s silent
// gap. The passive settle timer may finish stale streaming blocks, but
// must not drop pane-level loading state while the turn is still running.
const silentToolGap = createDeferred<void>();
const session = createMockSession({
events: async function* observedSparseTurn(
opts: { signal?: AbortSignal } = {},
) {
yield {
id: 9,
v: 1,
type: 'session_update',
originatorClientId: 'client-other',
data: {
update: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'run long task' },
},
},
};
yield {
id: 10,
v: 1,
type: 'session_update',
originatorClientId: 'client-other',
data: {
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'starting' },
},
},
};
await new Promise<void>((resolve) => {
if (opts.signal?.aborted) {
resolve();
return;
}
opts.signal?.addEventListener('abort', () => resolve(), {
once: true,
});
void silentToolGap.promise.then(() => resolve());
});
if (opts.signal?.aborted) return;
yield {
id: 11,
v: 1,
type: 'turn_complete',
timestamp: '2025-01-01T00:00:00.000Z',
sessionId: 'session-1',
data: { stopReason: 'end_turn' },
};
await new Promise<void>((resolve) => {
if (opts.signal?.aborted) {
resolve();
return;
}
opts.signal?.addEventListener('abort', () => resolve(), {
once: true,
});
});
},
});
sdkMocks.sessions.push(session);
let promptStatus: ReturnType<typeof useDaemonPromptStatus> = 'idle';
let streamingState: ReturnType<typeof useDaemonStreamingState> = 'idle';
function Harness() {
promptStatus = useDaemonPromptStatus();
streamingState = useDaemonStreamingState();
return null;
}
await renderWithProvider(<Harness />, { autoConnect: true });
await act(async () => {
await flushPromises();
await vi.advanceTimersByTimeAsync(20);
await flushPromises();
});
expect(promptStatus).not.toBe('idle');
// The observed turn goes quiet while a long tool call runs — well past
// the passive settle window. The loading state must survive the gap.
await act(async () => {
vi.advanceTimersByTime(10_000);
await flushPromises();
});
expect(promptStatus).not.toBe('idle');
expect(streamingState).not.toBe('idle');
await act(async () => {
silentToolGap.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(20);
await flushPromises();
});
expect(promptStatus).toBe('idle');
expect(streamingState).toBe('idle');
} finally {
vi.useRealTimers();
}
});
it('finishes replayed assistant streaming when replay completes', async () => {
vi.useFakeTimers();
try {

View file

@ -1459,7 +1459,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
activePromptsRef.current.has(`${activeSession.sessionId}:shell`);
hasCurrentSessionActivePrompt = hasSessionActivePrompt;
hasCurrentSessionActivePromptRef.current = hasSessionActivePrompt;
setPromptStatus(hasSessionActivePrompt() ? 'streaming' : 'idle');
setPromptStatus((current) =>
hasSessionActivePrompt() ? 'streaming' : current,
);
const pendingLoad = pendingSessionLoadRef.current;
const pendingLoadToResolve =
@ -2369,7 +2371,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
passiveAssistantDoneTimerRef,
'passive_observer',
3000,
() => setPromptStatus('idle'),
);
}
const pendingRepair = liveJournalRepairRef.current;