From a0244078f4531d7131301a133c16a9a5bd0851df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Sat, 22 Aug 2026 22:37:43 +0800 Subject: [PATCH] fix(web-shell): fall back after stale mid-turn rejection --- packages/web-shell/client/App.test.tsx | 28 +++++- packages/web-shell/client/App.tsx | 10 +-- .../client/components/ChatPane.test.tsx | 13 ++- .../web-shell/client/components/ChatPane.tsx | 11 ++- ...useQueuedPrompts.midTurnReconcile.test.tsx | 89 +++++++++++++++++++ .../client/hooks/useQueuedPrompts.ts | 43 +++++---- 6 files changed, 159 insertions(+), 35 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 2894f5afb48..a2be45529cb 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -373,6 +373,7 @@ const { messages: [] as unknown[], queuedPromptHoldHistory: [] as boolean[], queuedPromptStreamingState: 'idle', + queuedPromptSessionHasActivePrompt: false, chatEditorRenderCount: 0, latestChatEditorProps: null as ChatEditorTestProps | null, onChatEditorLayout: null as ((props: ChatEditorTestProps) => void) | null, @@ -605,11 +606,14 @@ vi.mock('./hooks/useQueuedPrompts', () => ({ useQueuedPrompts: (args: { holdQueuedPromptsLocally?: boolean; streamingState: string; + sessionHasActivePrompt?: boolean; }) => { testState.queuedPromptHoldHistory.push( args.holdQueuedPromptsLocally === true, ); testState.queuedPromptStreamingState = args.streamingState; + testState.queuedPromptSessionHasActivePrompt = + args.sessionHasActivePrompt === true; return { queuedPrompts: [], queuedTexts, @@ -4795,6 +4799,7 @@ beforeEach(() => { testState.messages = []; testState.queuedPromptHoldHistory = []; testState.queuedPromptStreamingState = 'idle'; + testState.queuedPromptSessionHasActivePrompt = false; testState.chatEditorRenderCount = 0; testState.latestChatEditorProps = null; testState.onChatEditorLayout = null; @@ -10475,7 +10480,7 @@ describe('App session callbacks', () => { mockConnection.missingSession = true; const onSessionIdChange = vi.fn(); - const { container } = renderApp({ + const { container, rerender } = renderApp({ onSessionIdChange, }); await flush(); @@ -10497,6 +10502,23 @@ describe('App session callbacks', () => { expect(mockSessionActions.attachSession).not.toHaveBeenCalled(); expect(onSessionIdChange).toHaveBeenCalledWith(undefined); expect(onSessionIdChange).toHaveBeenCalledTimes(1); + + mockConnection.status = 'connected'; + mockConnection.sessionId = undefined; + mockConnection.error = undefined; + mockConnection.errorStatus = undefined; + mockConnection.missingSession = false; + testState.sessionHasActivePrompt = false; + rerender(); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('first message'); + await flush(); + }); + + expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1); + expect(rawEnqueuePrompt).not.toHaveBeenCalled(); }, ); @@ -11130,7 +11152,8 @@ describe('App session callbacks', () => { expect(rawEnqueuePrompt.mock.calls[0]?.[0]).toBe( 'hello before first token', ); - expect(testState.queuedPromptStreamingState).toBe('responding'); + expect(testState.queuedPromptStreamingState).toBe('idle'); + expect(testState.queuedPromptSessionHasActivePrompt).toBe(true); mockSessionActions.sendPrompt.mockClear(); rawEnqueuePrompt.mockClear(); @@ -11144,6 +11167,7 @@ describe('App session callbacks', () => { }); expect(testState.queuedPromptStreamingState).toBe('idle'); + expect(testState.queuedPromptSessionHasActivePrompt).toBe(false); expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1); expect(rawEnqueuePrompt).not.toHaveBeenCalled(); }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 719c69252b3..f4043383685 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4227,10 +4227,6 @@ export function App({ const [isStartingNewSessionSuggestion, setIsStartingNewSessionSuggestion] = useState(false); const streamingState = useStreamingState(); - const queuedPromptStreamingState = - streamingState === 'idle' && sessionHasActivePrompt - ? 'responding' - : streamingState; const failedPromptRetryIsCurrent = Boolean( failedPromptRetry && retryOwnerMatchesCurrent( @@ -6315,7 +6311,8 @@ export function App({ canQueryMidTurn, canInjectMidTurnMedia, workspaceFileActions: artifactWorkspaceActions, - streamingState: queuedPromptStreamingState, + streamingState, + sessionHasActivePrompt, sessionActions, store, editorRef, @@ -12768,7 +12765,8 @@ export function App({ t={t} canMutateMidTurn={canMutateMidTurn} canInsertMidTurn={ - queuedPromptStreamingState !== 'idle' + streamingState !== 'idle' || + sessionHasActivePrompt } onDelete={removeQueuedPrompt} onInsert={insertQueuedPrompt} diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 0ba95df00c4..3156a768d98 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -33,6 +33,7 @@ let streamingStateValue: string; let pendingPermission: any; let sessionHasActivePromptValue: boolean; let queuedPromptStreamingState: string | undefined; +let queuedPromptSessionHasActivePrompt: boolean | undefined; let latestOnSubmit: | (( text: string, @@ -142,8 +143,12 @@ vi.mock('../session-catalog/session-catalog-hooks', () => ({ })); vi.mock('../hooks/useQueuedPrompts', () => ({ - useQueuedPrompts: (args: { streamingState: string }) => { + useQueuedPrompts: (args: { + streamingState: string; + sessionHasActivePrompt?: boolean; + }) => { queuedPromptStreamingState = args.streamingState; + queuedPromptSessionHasActivePrompt = args.sessionHasActivePrompt; return { queuedPrompts: queuedPromptsMock, queuedTexts: queuedTextsMock, @@ -409,6 +414,7 @@ beforeEach(() => { renderRealChatEditor = false; sessionHasActivePromptValue = false; queuedPromptStreamingState = undefined; + queuedPromptSessionHasActivePrompt = undefined; latestComposerCoreOptions.current = null; latestFollowupAccept = undefined; latestMonitorDetailsOnOpen = undefined; @@ -1657,6 +1663,7 @@ describe('ChatPane', () => { expect(sendPrompt).not.toHaveBeenCalled(); expect(enqueuePrompt).toHaveBeenCalled(); expect(queuedPromptStreamingState).toBe('responding'); + expect(queuedPromptSessionHasActivePrompt).toBe(false); }); it('inserts a prompt before the first stream event reaches the pane', () => { @@ -1671,7 +1678,8 @@ describe('ChatPane', () => { expect(sendPrompt).not.toHaveBeenCalled(); expect(enqueuePrompt).toHaveBeenCalled(); - expect(queuedPromptStreamingState).toBe('responding'); + expect(queuedPromptStreamingState).toBe('idle'); + expect(queuedPromptSessionHasActivePrompt).toBe(true); sendPrompt.mockClear(); enqueuePrompt.mockClear(); @@ -1686,6 +1694,7 @@ describe('ChatPane', () => { ); expect(queuedPromptStreamingState).toBe('idle'); + expect(queuedPromptSessionHasActivePrompt).toBe(false); expect(sendPrompt).toHaveBeenCalledTimes(1); expect(enqueuePrompt).not.toHaveBeenCalled(); }); diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 1275983e36f..f3da6dc64a1 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -273,10 +273,6 @@ export function ChatPane({ const transcriptHistory = useTranscriptHistory(); const store = useTranscriptStore(); const streamingState = useStreamingState(); - const queuedPromptStreamingState = - streamingState === 'idle' && sessionHasActivePrompt - ? 'responding' - : streamingState; const [goalControlBusy, setGoalControlBusy] = useState(false); const goalControlOpSeqRef = useRef(0); const goalControlOwnerRef = useRef< @@ -608,7 +604,8 @@ export function ChatPane({ canQueryMidTurn, canInjectMidTurnMedia, workspaceFileActions: attachmentWorkspaceTarget?.actions, - streamingState: queuedPromptStreamingState, + streamingState, + sessionHasActivePrompt, sessionActions: actions, store, editorRef, @@ -1351,7 +1348,9 @@ export function ChatPane({ prompts={queuedPrompts} t={t} canMutateMidTurn={canMutateMidTurn} - canInsertMidTurn={queuedPromptStreamingState !== 'idle'} + canInsertMidTurn={ + streamingState !== 'idle' || sessionHasActivePrompt + } onDelete={removeQueuedPrompt} onInsert={insertQueuedPrompt} onEdit={editQueuedPrompt} diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx index 19c85b97c10..3d4a5366f53 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx @@ -100,6 +100,7 @@ interface HarnessOptions { canQueryMidTurn?: boolean; canInjectMidTurnMedia?: boolean; streamingState?: DaemonStreamingState; + sessionHasActivePrompt?: boolean; holdQueuedPromptsLocally?: boolean; } @@ -155,6 +156,7 @@ function createHarness() { canInjectMidTurnMedia: opts.canInjectMidTurnMedia ?? true, workspaceFileActions: stableWorkspaceFileActions as never, streamingState: opts.streamingState ?? 'responding', + sessionHasActivePrompt: opts.sessionHasActivePrompt ?? false, holdQueuedPromptsLocally: opts.holdQueuedPromptsLocally ?? false, sessionActions: sdkMock.actions as never, store: stableStore as never, @@ -576,6 +578,93 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que } }); + it('falls back when live state is active but raw streaming is idle', async () => { + let resolveAdmission: + | ((value: { accepted: boolean; messageId?: string }) => void) + | undefined; + sdkMock.actions.enqueueMidTurnMessage.mockImplementation( + (_message: string, opts?: { onAdmissionStarted?: () => void }) => + new Promise((resolve) => { + opts?.onAdmissionStarted?.(); + resolveAdmission = resolve; + }), + ); + const harness = createHarness(); + try { + await harness.render({ + streamingState: 'idle', + sessionHasActivePrompt: true, + }); + await act(async () => { + harness.result().enqueuePrompt('live state race'); + }); + await act(async () => { + resolveAdmission?.({ accepted: false }); + }); + + expect(sdkMock.actions.enqueueMidTurnMessage).toHaveBeenCalledOnce(); + expect(sdkMock.actions.submitPrompt).toHaveBeenCalledOnce(); + expect(sdkMock.actions.submitPrompt).toHaveBeenCalledWith( + 'live state race', + expect.objectContaining({ sessionId: 'session-a' }), + ); + expect(harness.reportError).not.toHaveBeenCalled(); + } finally { + await harness.dispose(); + } + }); + + it('preserves file annotations when a live-state insert falls back', async () => { + const fileText = '@docs/notes.txt'; + const text = `${fileText} explain this`; + const annotation = { + type: 'reference' as const, + start: 0, + end: fileText.length, + text: fileText, + reference: { + id: 'file:docs/notes.txt', + kind: 'file' as const, + value: 'docs/notes.txt', + }, + }; + sdkMock.actions.enqueueMidTurnMessage.mockImplementationOnce( + (_message: string, opts?: { onAdmissionStarted?: () => void }) => { + opts?.onAdmissionStarted?.(); + return Promise.resolve({ accepted: false }); + }, + ); + const harness = createHarness(); + try { + await harness.render({ + streamingState: 'idle', + sessionHasActivePrompt: true, + }); + await act(async () => { + harness + .result() + .enqueuePrompt(text, undefined, undefined, undefined, [annotation]); + await Promise.resolve(); + }); + + expect(sdkMock.actions.removeAttachment).toHaveBeenCalledWith( + 'notes.txt', + { sessionId: 'session-a' }, + ); + expect(sdkMock.actions.submitPrompt).toHaveBeenCalledWith( + text, + expect.objectContaining({ + files: undefined, + inputAnnotations: [annotation], + sessionId: 'session-a', + }), + ); + expect(harness.reportError).not.toHaveBeenCalled(); + } finally { + await harness.dispose(); + } + }); + it('does not resubmit when an accepted response arrives after idle', async () => { let resolveAdmission: | ((value: { accepted: boolean; messageId?: string }) => void) diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index 7f50a5d43e6..5e485602b82 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -78,6 +78,7 @@ interface UseQueuedPromptsArgs { canInjectMidTurnMedia: boolean; workspaceFileActions?: Pick; streamingState: DaemonStreamingState; + sessionHasActivePrompt?: boolean; /** Keep ordinary submissions local until the Goal is paused, cleared, or the * user explicitly inserts one into the current turn. */ holdQueuedPromptsLocally?: boolean; @@ -403,6 +404,7 @@ export function useQueuedPrompts({ canInjectMidTurnMedia, workspaceFileActions, streamingState, + sessionHasActivePrompt = false, holdQueuedPromptsLocally = false, sessionActions, store, @@ -472,7 +474,10 @@ export function useQueuedPrompts({ >(new Map()); const appendedBeforeResponsePromptIdsRef = useRef>(new Set()); const removedBeforeResponsePromptIdsRef = useRef>(new Set()); - const latestStreamingStateRef = useRef(streamingState); + const latestRawStreamingStateRef = useRef(streamingState); + const latestSessionActiveRef = useRef( + streamingState !== 'idle' || sessionHasActivePrompt, + ); const holdQueuedPromptsLocallyRef = useRef(holdQueuedPromptsLocally); const refreshRequestSeqRef = useRef(0); /** Stale-response fence for `getMidTurnMessages` reconciliation calls. */ @@ -496,11 +501,12 @@ export function useQueuedPrompts({ latestSessionIdRef.current = sessionId; latestWorkspaceCwdRef.current = workspaceCwd; holdQueuedPromptsLocallyRef.current = holdQueuedPromptsLocally; - const streamingIdle = streamingState === 'idle'; + const sessionActive = streamingState !== 'idle' || sessionHasActivePrompt; useLayoutEffect(() => { midTurnReconcileSeqRef.current += 1; - }, [streamingIdle]); - latestStreamingStateRef.current = streamingState; + }, [sessionActive]); + latestRawStreamingStateRef.current = streamingState; + latestSessionActiveRef.current = sessionActive; const visibleQueuedPrompts = queuedPromptsOwnerRef.current === ownerToken ? queuedPrompts : []; @@ -1149,7 +1155,7 @@ export function useQueuedPrompts({ prevPendingVersionRef.current = pendingPromptVersion; if (!versionChanged) { if (!canQueryMidTurn && queuedPromptsRef.current.length > 0) return; - if (streamingState === 'idle' && !canQueryMidTurn) return; + if (!sessionActive && !canQueryMidTurn) return; if (initialRefreshSessionIdRef.current === sessionId) return; initialRefreshSessionIdRef.current = sessionId; } @@ -1163,7 +1169,7 @@ export function useQueuedPrompts({ pendingPromptVersion, connected, sessionId, - streamingState, + sessionActive, canQueryMidTurn, ownerToken, refreshPendingPrompts, @@ -1387,7 +1393,7 @@ export function useQueuedPrompts({ displayedServerPromptIdsRef.current.delete(result.promptId); return; } - if (latestStreamingStateRef.current === 'idle') { + if (!latestSessionActiveRef.current) { if (!localMessageAppended) { appendLocalQueuedPrompt(prompt, result.promptId); } @@ -1591,7 +1597,7 @@ export function useQueuedPrompts({ workspaceFileActions !== undefined; const shouldInsertMidTurn = !holdQueuedPromptsLocallyRef.current && - latestStreamingStateRef.current !== 'idle' && + latestSessionActiveRef.current && (imageList.length === 0 || canSendMidTurnMedia) && (fileList.length === 0 || canSendMidTurnFiles) && annotated !== undefined && @@ -1796,13 +1802,13 @@ export function useQueuedPrompts({ // runs) instead of dropping it. if ( targetIsCurrent() && - latestStreamingStateRef.current === 'idle' + latestRawStreamingStateRef.current === 'idle' ) { const shouldHold = holdQueuedPromptsLocallyRef.current || writeBlockedRef.current; const prompt: QueuedPrompt = { - ...pendingAdmission, + ...restoreAdmission, midTurnState: undefined, midTurnMessageId: undefined, ...(shouldHold ? {} : { serverState: 'submitting' as const }), @@ -1973,7 +1979,7 @@ export function useQueuedPrompts({ fallbackToPendingPrompt(prompt.id); return; } - if (latestStreamingStateRef.current === 'idle') { + if (!latestSessionActiveRef.current) { const next = current.filter((item) => item.id !== prompt.id); queuedPromptsRef.current = next; setQueuedPrompts(next); @@ -2065,7 +2071,7 @@ export function useQueuedPrompts({ ]); useEffect(() => { - if (streamingState !== 'idle' || writeBlocked) return; + if (sessionActive || writeBlocked) return; if (!canQueryMidTurn) { const acceptedIds = new Set( queuedPromptsRef.current @@ -2167,7 +2173,7 @@ export function useQueuedPrompts({ reconcileCtrl.abort(); }; }, [ - streamingState, + sessionActive, writeBlocked, holdQueuedPromptsLocally, canQueryMidTurn, @@ -2364,7 +2370,7 @@ export function useQueuedPrompts({ ); return false; } - const settledAtIdle = latestStreamingStateRef.current === 'idle'; + const settledAtIdle = !latestSessionActiveRef.current; if (settledAtIdle) { const next = current.filter((prompt) => prompt.id !== target.id); queuedPromptsRef.current = next; @@ -2405,7 +2411,7 @@ export function useQueuedPrompts({ reportError(error, fallback); return false; } - const settledAtIdle = latestStreamingStateRef.current === 'idle'; + const settledAtIdle = !latestSessionActiveRef.current; if (settledAtIdle) { const next = queuedPromptsRef.current.filter( (prompt) => prompt.id !== target.id, @@ -2475,7 +2481,7 @@ export function useQueuedPrompts({ const prompt = queuedPromptsRef.current.find((item) => item.id === id); if ( !canMutateMidTurn || - latestStreamingStateRef.current === 'idle' || + !latestSessionActiveRef.current || !prompt || prompt.serverState !== undefined || prompt.serverPromptId !== undefined || @@ -2579,8 +2585,7 @@ export function useQueuedPrompts({ const submitAtIdle = isCurrentOwnerTokenRef.current(insertionOwnerToken) && insertOwnerMatches() && - (latestStreamingStateRef.current as DaemonStreamingState) === - 'idle' && + !latestSessionActiveRef.current && !writeBlockedRef.current && !holdQueuedPromptsLocallyRef.current; const nextFlags = { @@ -2686,7 +2691,7 @@ export function useQueuedPrompts({ const index = current.findIndex((item) => item.id === prompt.id); const acceptedAtLegacyIdle = insertOwnerMatches() && - (latestStreamingStateRef.current as DaemonStreamingState) === 'idle' && + !latestSessionActiveRef.current && !canQueryMidTurn; if (index === -1) { const stashKey = currentStashKey();