mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-23 23:55:50 +00:00
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:
parent
9f2342d323
commit
fb870e9ca3
2 changed files with 110 additions and 2 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue