diff --git a/docs/design/web-shell-optional-transport-capabilities.md b/docs/design/web-shell-optional-transport-capabilities.md new file mode 100644 index 00000000000..e7b9c89410c --- /dev/null +++ b/docs/design/web-shell-optional-transport-capabilities.md @@ -0,0 +1,25 @@ +# Web Shell optional transport capabilities + +[English](web-shell-optional-transport-capabilities.md) | [简体中文](web-shell-optional-transport-capabilities.zh-CN.md) + +## Problem and scope + +An embedded transport can support streaming prompts without exposing the optional daemon recap or native pending-prompt routes. Automatic recap and queue reconciliation must respect advertised capabilities. When `optimisticUserMessage=false`, the authoritative user echo also arrives after the browser starts sending; using the previous user message as the clock anchor briefly shows the previous turn's duration. + +This change corrects browser behavior against existing transport contracts. It does not add daemon routes, authentication mechanisms, or persistent client queues. + +## Decisions and constraints + +- Register automatic recap only when `session_recap` is advertised. +- Query native pending prompts only when `session_prompt` is advertised. Without it, hold ordinary queued prompts locally while the session is active, then send them sequentially through the normal streaming action and await completion. Recheck ownership, cancellation and host dispatch policy before dispatch. Do not automatically replay an uncertain submission. +- Keep mid-turn injection independent from native queue support. An explicit host hold still blocks it; missing native queue support alone does not block an advertised mid-turn capability. +- Do not pair urgent prompt state with a deferred snapshot from an earlier transcript structure. Pure streaming tail appends retain the deferred path. +- A browser-owned send records its session ID and local start time until the send settles. Both chat layouts use it before an authoritative user echo arrives. Cleanup removes only the same operation's record, so a stale completion cannot clear a newer owner. This state is not sent to the daemon. Remote restored turns retain their authoritative start time. + +Local queue entries remain browser-local and are not guaranteed to survive refresh. A transport lacking native queue support cannot expose other clients' pending native prompts. The native-capability path keeps its existing behavior. + +## Validation and acceptance + +Reproduce missing-capability calls, native queue dispatch on a streaming-only transport, and a clock anchored one hour in the past before changing production code. Verify positive capability controls, sequential completion, failed-send handling, independent mid-turn support, and restored remote timing. Verify local clock cleanup for completion, cancellation, rejection and replacement ownership. Keep UI structure consistency and streaming-throttle coverage. + +Component and protocol-fixture tests must distinguish their evidence from a browser end-to-end run. No live account or provider-specific endpoint is required for these regressions. Before merging, run the affected tests, build/bundle, typecheck and formatting/lint checks. Browser validation of a host integration remains separate from those gates. diff --git a/docs/design/web-shell-optional-transport-capabilities.zh-CN.md b/docs/design/web-shell-optional-transport-capabilities.zh-CN.md new file mode 100644 index 00000000000..026935d4745 --- /dev/null +++ b/docs/design/web-shell-optional-transport-capabilities.zh-CN.md @@ -0,0 +1,25 @@ +# Web Shell 可选传输能力 + +[English](web-shell-optional-transport-capabilities.md) | [简体中文](web-shell-optional-transport-capabilities.zh-CN.md) + +## 问题与范围 + +嵌入式传输可以支持流式 Prompt,而不提供可选的 daemon 摘要和原生待处理 Prompt 路由。自动摘要和队列同步必须遵守能力声明。使用 `optimisticUserMessage=false` 时,权威用户回显还会晚于浏览器开始发送;以旧用户消息作为计时起点,会短暂显示上一轮的耗时。 + +本变更修正浏览器对现有传输契约的处理,不新增 daemon 路由、认证机制或持久化客户端队列。 + +## 决策与约束 + +- 仅在声明 `session_recap` 时注册自动摘要。 +- 仅在声明 `session_prompt` 时查询原生待处理 Prompt。缺少该能力时,会话活跃期间将普通排队消息保留在本地,随后通过普通流式动作逐条发送并等待完成。发送前重新检查归属、取消和宿主策略。提交结果不明确时不自动重放。 +- 中途注入与原生排队能力独立。显式宿主 hold 仍然阻止注入;仅缺少原生排队能力不能阻止已声明的中途注入能力。 +- 紧急 Prompt 状态不能配对此前转录结构的延迟快照。纯流式尾部追加仍保留延迟路径。 +- 浏览器发起的 send 在结束前记录其会话 ID 和本地开始时间。两种聊天布局在权威用户回显之前使用该起点。清理仅移除同一次操作的记录,避免旧请求完成后清掉新归属的状态。此状态不发给 daemon。远端恢复中的轮次仍保留权威起点。 + +本地队列条目仍只存在于浏览器,不能保证刷新后保留。缺少原生排队能力的传输不能展示其他客户端的原生待处理消息。具备原生能力的路径保留既有行为。 + +## 验证与验收 + +修改生产源码前,复现缺少能力时仍然调用接口、仅流式传输误走原生队列,以及用户回显前计时起点旧了一小时。验证具备能力的对照、逐条完成、发送失败处理、中途注入独立性和远端恢复计时。验证本地计时在完成、取消、拒绝及归属替换后的清理,保留 UI 结构一致性和流式节流覆盖。 + +组件及协议夹具测试必须与浏览器端到端验证区分。这些回归不依赖真实账号或特定服务商入口。合入前运行受影响测试、构建及 bundle、类型和格式/lint 检查。宿主集成的浏览器验证仍是独立验收。 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 6d501a135a9..4bbb4c6a382 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -12174,7 +12174,7 @@ function visibleRecapTexts(): string[] { ); } -async function triggerAutoRecap(): Promise<{ +async function triggerAutoRecap(supported = true): Promise<{ recap: ReturnType< typeof deferred<{ sessionId: string; recap: string | null }> >; @@ -12183,6 +12183,7 @@ async function triggerAutoRecap(): Promise<{ }> { const recap = deferred<{ sessionId: string; recap: string | null }>(); mockSessionActions.recapSession.mockReturnValueOnce(recap.promise); + mockConnection.capabilities.features = supported ? ['session_recap'] : []; testState.blocks = [{}, {}, {}, {}]; let hidden = true; Object.defineProperty(document, 'hidden', { @@ -12198,7 +12199,9 @@ async function triggerAutoRecap(): Promise<{ now += 3 * 60 * 1000; hidden = false; act(() => document.dispatchEvent(new Event('visibilitychange'))); - expect(mockSessionActions.recapSession).toHaveBeenCalledOnce(); + expect(mockSessionActions.recapSession).toHaveBeenCalledTimes( + supported ? 1 : 0, + ); return { recap, container, rerender }; } @@ -26881,6 +26884,11 @@ describe('App session callbacks', () => { }); }); + it('does not request an automatic recap without the advertised capability', async () => { + await triggerAutoRecap(false); + expect(visibleRecapTexts()).toEqual([]); + }); + it('shows an automatic recap when the session remains active', async () => { const { recap } = await triggerAutoRecap(); await act(async () => { @@ -27045,6 +27053,7 @@ describe('App session callbacks', () => { }); it('keeps only the newest automatic recap across away cycles', async () => { + mockConnection.capabilities.features = ['session_recap']; const first = deferred<{ sessionId: string; recap: string | null }>(); const second = deferred<{ sessionId: string; recap: string | null }>(); mockSessionActions.recapSession @@ -27141,6 +27150,7 @@ describe('App session callbacks', () => { }); it('does not strand the manual recap placeholder when a newer request supersedes it', async () => { + mockConnection.capabilities.features = ['session_recap']; const manual = deferred<{ sessionId: string; recap: string | null }>(); const auto = deferred<{ sessionId: string; recap: string | null }>(); mockSessionActions.recapSession @@ -49029,6 +49039,33 @@ describe('App prompt send failure retry', () => { ).not.toBeNull(); }); + it('anchors a local send before the authoritative user echo arrives', async () => { + testState.messages = [ + { + id: 'old-user', + role: 'user', + content: 'old question', + timestamp: 1000, + }, + ]; + testState.streamingState = 'waiting'; + Object.assign(mockConnection, { + localPrompt: { sessionId: mockConnection.sessionId, startedAt: 9000 }, + }); + try { + const { container } = renderApp(); + await flush(); + expect( + container + .querySelector('[data-testid="streaming-status"]') + ?.getAttribute('data-started-at'), + ).toBe('9000'); + expect(testState.latestMessageListProps?.activeTurnStartedAt).toBe(9000); + } finally { + Reflect.deleteProperty(mockConnection, 'localPrompt'); + } + }); + it('shows processing only after retry admission and restarts its timer', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const firstSend = deferred(); @@ -54853,3 +54890,93 @@ describe('Managed home integration', () => { }, ); }); + +describe('upstream embedded transport reproduction', () => { + it('does not automatically recap when the transport omits session_recap', async () => { + mockConnection.capabilities = { qwenCodeVersion: 'test', features: [] }; + mockSessionActions.recapSession.mockResolvedValue({ + sessionId: 'session-1', + recap: null, + }); + testState.blocks = [{}, {}, {}, {}]; + let hidden = true; + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => hidden, + }); + let now = 1; + vi.spyOn(Date, 'now').mockImplementation(() => now); + renderApp(); + await flush(); + act(() => document.dispatchEvent(new Event('visibilitychange'))); + now += 3 * 60 * 1000; + hidden = false; + act(() => document.dispatchEvent(new Event('visibilitychange'))); + expect(mockSessionActions.recapSession).not.toHaveBeenCalled(); + }); + + it('starts the current local clock before the authoritative user echo', async () => { + const pending = deferred(); + const now = Date.now(); + testState.messages = [ + { + id: 'previous-user', + role: 'user', + content: 'previous question', + timestamp: now - 3_600_000, + }, + ]; + mockSessionActions.sendPrompt.mockImplementationOnce(() => { + Object.assign(mockConnection, { + localPrompt: { + sessionId: mockConnection.sessionId, + startedAt: Date.now(), + }, + }); + return pending.promise; + }); + const { rerender } = renderApp(); + await flush(); + try { + await act(async () => { + testState.latestChatEditorProps?.onSubmit('new question'); + await Promise.resolve(); + }); + await vi.waitFor(() => + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + 'new question', + expect.anything(), + ), + ); + act(() => { + testState.streamingState = 'waiting'; + rerender(); + }); + expect( + testState.latestMessageListProps?.activeTurnStartedAt, + ).toBeGreaterThanOrEqual(now); + } finally { + await act(async () => { + pending.resolve(); + await pending.promise; + }); + Reflect.deleteProperty(mockConnection, 'localPrompt'); + } + }); + + it('preserves the original remote turn anchor when no local send exists', async () => { + testState.messages = [ + { + id: 'remote-user', + role: 'user', + content: 'remote question', + timestamp: 1000, + }, + ]; + testState.streamingState = 'waiting'; + renderApp(); + await flush(); + expect(testState.latestMessageListProps?.activeTurnStartedAt).toBe(1000); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 89c51126112..aa4d3c204a6 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -9252,6 +9252,12 @@ export function App({ } previousStreamingStateRef.current = streamingState; const activeTurnStartedAt = useMemo(() => { + if ( + connection.localPrompt && + connection.localPrompt.sessionId === connection.sessionId + ) { + return connection.localPrompt.startedAt; + } if (connection.backgroundTurn) return connection.backgroundTurn.startedAt; if (streamingState === 'idle') return undefined; for (let i = displayMessages.length - 1; i >= 0; i--) { @@ -9261,7 +9267,13 @@ export function App({ } } return localStreamingStartedAtRef.current; - }, [displayMessages, streamingState, connection.backgroundTurn]); + }, [ + displayMessages, + streamingState, + connection.backgroundTurn, + connection.localPrompt, + connection.sessionId, + ]); const lastSubmittedPromptRef = useRef(''); const lastSubmittedImagesRef = useRef(undefined); const lastSubmittedFilesRef = useRef(undefined); @@ -13500,6 +13512,8 @@ export function App({ clientId: connection.clientId, canMutateMidTurn, canQueryMidTurn, + canQueryPendingPrompts: + connection.capabilities?.features.includes('session_prompt') === true, canInjectMidTurnMedia, workspaceFileActions: artifactWorkspaceActions, streamingState, @@ -15657,6 +15671,8 @@ export function App({ autoRecapVersionRef.current += 1; }, [logicalSessionKey]); useEffect(() => { + // OpenAPI and Managed hosts may not expose the daemon's recap route. + if (!connection.capabilities?.features.includes('session_recap')) return; const AWAY_THRESHOLD_MS = 3 * 60 * 1000; const MIN_NEW_BLOCKS = 4; function onVisibilityChange() { @@ -15727,6 +15743,7 @@ export function App({ return () => document.removeEventListener('visibilitychange', onVisibilityChange); }, [ + connection.capabilities, connection.sessionId, sessionActions, sessionOwnerGuard, diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 5b2676f33b8..77eebfbe723 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -3174,6 +3174,20 @@ describe('ChatPane', () => { ); }); + it('uses this local send clock while the server user echo has not arrived', () => { + streamingStateValue = 'waiting'; + messagesState = [ + { id: 'old-user', role: 'user', content: 'old', timestamp: 1000 }, + ]; + Object.assign(connectionState, { + localPrompt: { sessionId: connectionState.sessionId, startedAt: 9000 }, + }); + render(); + expect(testid('pane-streaming')?.getAttribute('data-started-at')).toBe( + '9000', + ); + }); + it('enables the interactive composer controls', () => { connectionState.tokenCount = 1200; connectionState.contextWindow = 8192; diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index a9cad349ab9..718c704ba68 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -774,6 +774,8 @@ export function ChatPane({ clientId: connection.clientId, canMutateMidTurn, canQueryMidTurn, + canQueryPendingPrompts: + connection.capabilities?.features.includes('session_prompt') === true, canInjectMidTurnMedia, workspaceFileActions: attachmentWorkspaceTarget?.actions, streamingState, @@ -789,6 +791,12 @@ export function ChatPane({ // timestamp) rather than letting StreamingStatus fall back to "now" — so a // pane opened mid-turn shows the real elapsed time, not a reset-to-zero clock. const activeTurnStartedAt = useMemo(() => { + if ( + connection.localPrompt && + connection.localPrompt.sessionId === connection.sessionId + ) { + return connection.localPrompt.startedAt; + } if (connection.backgroundTurn) return connection.backgroundTurn.startedAt; if (!isResponding) return undefined; for (let i = messages.length - 1; i >= 0; i--) { @@ -796,7 +804,13 @@ export function ChatPane({ if (message?.role === 'user') return message.timestamp; } return undefined; - }, [messages, isResponding, connection.backgroundTurn]); + }, [ + messages, + isResponding, + connection.backgroundTurn, + connection.localPrompt, + connection.sessionId, + ]); const controlGoal = useCallback( async ( diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index d171cedc02b..892809dff40 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -266,6 +266,64 @@ describe('context usage counter reconciliation', () => { ); }); +describe('local prompt timing before the authoritative echo', () => { + it.each(['completed', 'cancelled'] as const)( + 'clears its local clock after an admitted turn is %s', + async (outcome) => { + const session = createMockSession('session-a'); + const h = createActionsHarness({ session }); + const result = h.actions.sendPrompt('new question', { + optimisticUserMessage: false, + }); + await vi.waitFor(() => + expect(h.activePromptsRef.current.get('session-a')?.resolve).toBeTypeOf( + 'function', + ), + ); + expect(h.getConnection().localPrompt?.sessionId).toBe('session-a'); + const active = h.activePromptsRef.current.get('session-a')!; + if (outcome === 'completed') active.resolve?.({ stopReason: 'end_turn' }); + else active.controller.abort(); + await result; + expect(h.getConnection().localPrompt).toBeUndefined(); + expect(h.store.appendLocalUserMessage).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'clears only its own clock after rejection (newer owner: %s)', + async (newerOwner) => { + const session = createMockSession('session-a'); + const admission = createDeferred<{ promptId: string }>(); + session.submitPrompt.mockReturnValueOnce(admission.promise); + const h = createActionsHarness({ session }); + const before = Date.now(); + const result = h.actions.sendPrompt('new question', { + optimisticUserMessage: false, + }); + const rejected = result.catch((error: unknown) => error); + await vi.waitFor(() => + expect(session.submitPrompt).toHaveBeenCalledOnce(), + ); + const local = h.getConnection().localPrompt; + expect(local?.sessionId).toBe('session-a'); + expect(local?.startedAt).toBeGreaterThanOrEqual(before); + expect(h.store.appendLocalUserMessage).not.toHaveBeenCalled(); + const replacement = { + sessionId: 'session-b', + startedAt: Date.now() + 1000, + }; + if (newerOwner) + h.replaceConnection({ ...h.getConnection(), localPrompt: replacement }); + admission.reject(new DaemonHttpError(413, {}, 'Rejected')); + await rejected; + expect(h.getConnection().localPrompt).toEqual( + newerOwner ? replacement : undefined, + ); + }, + ); +}); + describe('sendPrompt admission notices', () => { it.each([ { diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index e152f216ba7..2a59c8ebf84 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -1322,6 +1322,8 @@ export function createDaemonSessionActions({ ); } clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); + const localPrompt = { sessionId, startedAt: Date.now() }; + setConnection((current) => ({ ...current, localPrompt })); setPromptStatus('waiting'); const ctrl = new AbortController(); activePromptsRef.current.set(sessionId, { @@ -1529,6 +1531,12 @@ export function createDaemonSessionActions({ 'send_prompt', ); } finally { + setConnection((current) => { + if (current.localPrompt !== localPrompt) return current; + const next = { ...current }; + delete next.localPrompt; + return next; + }); const active = activePromptsRef.current.get(sessionId); if (active?.controller === ctrl) { activePromptsRef.current.delete(sessionId); diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 578c9970166..8e25ed2c7aa 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -158,6 +158,8 @@ export interface DaemonConnectionState { /** Authoritative Goal v2 state for the current session. */ goalState?: GoalSnapshotV2; backgroundTurn?: DaemonBackgroundTurn; + /** Browser-owned send, including the gap before an authoritative user echo. */ + localPrompt?: { sessionId: string; startedAt: number }; /** Stops a lagging live-state snapshot from reviving the finished execution. */ finishedBackgroundTurnId?: string; /** Local monotonic time; orders background events against live-state requests. */ diff --git a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx index a8c72a9f3bf..185272c0926 100644 --- a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx +++ b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx @@ -411,4 +411,26 @@ describe('useAnimationFrameTranscriptSnapshot', () => { expect(renderLog.some((entry) => entry.ids.includes('old'))).toBe(false); expect(latestBlocks).toEqual([]); }); + + it('does not show the previous user turn when a new prompt changes urgent state', () => { + const oldUser = { id: 'old-user', kind: 'user' } as DaemonTranscriptBlock; + const newUser = { id: 'new-user', kind: 'user' } as DaemonTranscriptBlock; + testStore.update([oldUser]); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + act(() => root!.render()); + renderLog = []; + + // Prompt status changes urgently while useDeferredValue still holds the + // previous transcript. It must not pair a new spinner with the old turn. + act(() => { + testStore.update([oldUser, newUser]); + root!.render(); + }); + expect(renderLog.length).toBeGreaterThan(0); + for (const entry of renderLog) { + expect(entry.ids).toEqual(['old-user', 'new-user']); + } + }); }); diff --git a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts index 80c22ab7578..f91f0cce558 100644 --- a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts +++ b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts @@ -146,12 +146,20 @@ export function useAnimationFrameTranscriptSnapshot( // // Session and block-index identities ride inside the deferred snapshot. The // session id rejects a previous session, while the index identity rejects a - // same-session store reset without blocking ordinary streamed text updates. + // same-session store reset. Structural changes (including a new user turn) + // must also be current: urgent prompt status must not render against the + // previous turn and its elapsed-time anchor. Pure streaming tail appends + // retain the deferred path. const snapshot = useMemo(() => ({ sessionId, ...live }), [live, sessionId]); const deferred = useDeferredValue(snapshot); const current = deferred.sessionId === sessionId && - deferred.blockIndexById === live.blockIndexById + deferred.blockIndexById === live.blockIndexById && + (deferred.blockChangeSummary === live.blockChangeSummary || + isSameTranscriptStructure( + deferred.blockChangeSummary, + live.blockChangeSummary, + )) ? deferred : live; return { diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx index 4158043beda..9c3d460d923 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx @@ -87,6 +87,8 @@ function mount( // `null` = the workspace has not resolved yet (an explicit `undefined` // argument would take the default). workspaceCwd: string | null = '/workspace', + canQueryPendingPrompts = true, + getPromptDispatchError?: (text: string) => string | undefined, ) { const editor = { getText: vi.fn(() => ''), @@ -117,6 +119,7 @@ function mount( stopped: boolean; }) { latest = useQueuedPrompts({ + getPromptDispatchError, connected, writeBlocked: blocked || stopped, runtimeStopped: stopped, @@ -126,6 +129,7 @@ function mount( canMutateMidTurn, // This suite pins the legacy local-fallback lifecycle. canQueryMidTurn: false, + canQueryPendingPrompts, canInjectMidTurnMedia: false, streamingState: state, holdQueuedPromptsLocally: hold, @@ -187,6 +191,7 @@ function createActions() { removePendingPrompt: vi.fn(), getPendingPrompts: vi.fn().mockResolvedValue({ pendingPrompts: [] }), submitPrompt: vi.fn(() => pendingSubmit.promise), + sendPrompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), sendMidTurnMessagesNow: vi.fn(), } as unknown as DaemonSessionActions, pendingSubmit, @@ -209,6 +214,156 @@ afterEach(() => { }); describe('useQueuedPrompts default mid-turn insertion', () => { + it('reports a streaming fallback policy block without restoring the message', async () => { + const { actions } = createActions(); + const policy = { error: undefined as string | undefined }; + vi.mocked(actions.sendPrompt).mockImplementation(async (_text, options) => { + options?.beforeDispatch?.(); + return { stopReason: 'end_turn' }; + }); + const { render, editor, reportError } = mount( + 'responding', + actions, + false, + true, + false, + false, + '/workspace', + false, + () => policy.error, + ); + act(() => latest.enqueuePrompt('queued before policy changed')); + expect(latest.queuedTexts).toEqual(['queued before policy changed']); + policy.error = 'Sending is now disabled by host policy'; + await act(async () => render('idle')); + expect(reportError).toHaveBeenCalledWith( + expect.objectContaining({ message: policy.error }), + t('queue.queueFailed'), + ); + expect(editor.setText).not.toHaveBeenCalled(); + expect(latest.queuedPrompts).toEqual([]); + await act(async () => render('idle')); + expect(actions.sendPrompt).toHaveBeenCalledTimes(1); + }); + + it('does not resurrect a cancelled streaming fallback message or report an error', async () => { + const { actions } = createActions(); + const preflight = deferred(); + vi.mocked(actions.sendPrompt).mockImplementation(async (_text, options) => { + await preflight.promise; + options?.beforeDispatch?.(); + return { stopReason: 'end_turn' }; + }); + const { render, editor, reportError } = mount( + 'responding', + actions, + false, + true, + false, + false, + '/workspace', + false, + ); + act(() => latest.enqueuePrompt('cancel this queued message')); + await act(async () => render('idle')); + expect(actions.sendPrompt).toHaveBeenCalledTimes(1); + expect(latest.queuedPrompts[0]?.serverState).toBe('submitting'); + await act(async () => latest.removeQueuedPrompt(1)); + expect(latest.queuedPrompts).toEqual([]); + await act(async () => { + preflight.resolve(); + await preflight.promise; + }); + expect(editor.setText).not.toHaveBeenCalled(); + expect(reportError).not.toHaveBeenCalled(); + await act(async () => render('idle')); + expect(actions.sendPrompt).toHaveBeenCalledTimes(1); + }); + + it('keeps the local queue usable without requesting unsupported native queue routes', async () => { + const { actions } = createActions(); + const { render } = mount( + 'responding', + actions, + false, + true, + false, + false, + '/workspace', + false, + ); + await act(async () => {}); + expect(actions.getPendingPrompts).not.toHaveBeenCalled(); + + act(() => latest.enqueuePrompt('follow-up question')); + expect(actions.sendPrompt).not.toHaveBeenCalled(); + expect(latest.queuedTexts).toEqual(['follow-up question']); + await act(async () => render('idle')); + expect(actions.sendPrompt).toHaveBeenCalledWith( + 'follow-up question', + expect.objectContaining({ beforeDispatch: expect.any(Function) }), + ); + expect(actions.getPendingPrompts).not.toHaveBeenCalled(); + }); + + it('waits for a local streaming turn to finish before sending the next queued message', async () => { + const { actions } = createActions(); + const first = deferred<{ stopReason: string }>(); + vi.mocked(actions.sendPrompt).mockReturnValueOnce(first.promise as never); + const { render } = mount( + 'responding', + actions, + false, + true, + false, + false, + '/workspace', + false, + ); + act(() => { + latest.enqueuePrompt('first'); + latest.enqueuePrompt('second'); + }); + await act(async () => render('idle')); + expect(actions.sendPrompt).toHaveBeenCalledTimes(1); + expect(vi.mocked(actions.sendPrompt).mock.calls[0]?.[0]).toBe('first'); + await act(async () => { + first.resolve({ stopReason: 'end_turn' }); + await first.promise; + }); + expect( + vi.mocked(actions.sendPrompt).mock.calls.map(([text]) => text), + ).toEqual(['first', 'second']); + expect(latest.queuedTexts).toEqual([]); + expect(actions.submitPrompt).not.toHaveBeenCalled(); + expect(actions.getPendingPrompts).not.toHaveBeenCalled(); + }); + + it('restores a locally queued message after a send fails before dispatch without replaying it', async () => { + const { actions } = createActions(); + vi.mocked(actions.sendPrompt).mockRejectedValueOnce( + new Error('offline before dispatch'), + ); + const { render, editor, reportError } = mount( + 'responding', + actions, + false, + true, + false, + false, + '/workspace', + false, + ); + act(() => { + latest.enqueuePrompt('retry manually'); + }); + await act(async () => render('idle')); + expect(editor.setText).toHaveBeenCalledWith('retry manually'); + expect(reportError).toHaveBeenCalled(); + await act(async () => render('idle')); + expect(actions.sendPrompt).toHaveBeenCalledTimes(1); + }); + it.each([undefined, ' original question\n'])( 'keeps an optional original submission separate from the queued payload: %s', async (submittedPrompt) => { @@ -2510,3 +2665,54 @@ it('runtime stop fences the live queue stashed while the workspace cwd is unreso expect(actions.submitPrompt).not.toHaveBeenCalled(); expect(editor.setText).toHaveBeenCalledWith('queued before cwd resolved'); }); + +describe('upstream streaming-only transport reproduction', () => { + it('does not probe an unsupported native pending endpoint', async () => { + const { actions } = createActions(); + vi.mocked(actions.getPendingPrompts).mockRejectedValue( + new Error('native pending prompts unsupported'), + ); + mount( + 'responding', + actions, + false, + true, + false, + false, + '/workspace', + false, + ); + await act(async () => { + await Promise.resolve(); + }); + expect(actions.getPendingPrompts).not.toHaveBeenCalled(); + }); + + it('releases a local message through the supported streaming prompt action', async () => { + const { actions } = createActions(); + actions.sendPrompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); + vi.mocked(actions.submitPrompt).mockRejectedValue( + new DaemonHttpError(404, {}, 'native queue unsupported'), + ); + const { render } = mount( + 'responding', + actions, + false, + true, + false, + true, + '/workspace', + false, + ); + act(() => latest.enqueuePrompt('queued question')); + await act(async () => { + render('idle', 'session-1', false, false, false); + await Promise.resolve(); + }); + expect(actions.submitPrompt).not.toHaveBeenCalled(); + expect(actions.sendPrompt).toHaveBeenCalledWith( + 'queued question', + expect.anything(), + ); + }); +}); diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx index 903dfc22499..d7252d6af32 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx @@ -112,6 +112,7 @@ interface HarnessOptions { clientId?: string; canMutateMidTurn?: boolean; canQueryMidTurn?: boolean; + canQueryPendingPrompts?: boolean; canInjectMidTurnMedia?: boolean; streamingState?: DaemonStreamingState; sessionHasActivePrompt?: boolean; @@ -170,6 +171,7 @@ function createHarness(language?: 'en' | 'zh-CN') { clientId: opts.clientId ?? CLIENT_ID, canMutateMidTurn: opts.canMutateMidTurn ?? true, canQueryMidTurn: opts.canQueryMidTurn ?? true, + canQueryPendingPrompts: opts.canQueryPendingPrompts ?? true, canInjectMidTurnMedia: opts.canInjectMidTurnMedia ?? true, workspaceFileActions: stableWorkspaceFileActions as never, streamingState: opts.streamingState ?? 'responding', @@ -294,6 +296,26 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que }, ); + it('keeps mid-turn injection independent of native queue support', async () => { + const harness = createHarness(); + try { + await harness.render({ + canQueryPendingPrompts: false, + canQueryMidTurn: true, + canMutateMidTurn: true, + sessionHasActivePrompt: true, + }); + await act(async () => { + harness.result().enqueuePrompt('steer the active turn'); + }); + expect(sdkMock.actions.enqueueMidTurnMessage).toHaveBeenCalledOnce(); + expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled(); + expect(sdkMock.actions.getPendingPrompts).not.toHaveBeenCalled(); + } finally { + await harness.dispose(); + } + }); + it('admits a message typed during a turn without asking to send it now', async () => { const harness = createHarness(); try { diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index 37c0b6b0a84..6e76d71c62a 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -80,6 +80,9 @@ interface UseQueuedPromptsArgs { * the legacy local fallback used by older daemons. */ canQueryMidTurn: boolean; + /** Whether `session_prompt` advertises the native prompt queue routes. + * OpenAPI hosts can send prompts without exposing the daemon queue. */ + canQueryPendingPrompts: boolean; /** * Whether the daemon advertises `session_attachments`. With it, * attachments travel with a mid-turn message and are injected into the @@ -515,6 +518,7 @@ export function useQueuedPrompts({ clientId, canMutateMidTurn, canQueryMidTurn, + canQueryPendingPrompts, canInjectMidTurnMedia, workspaceFileActions, streamingState, @@ -526,6 +530,8 @@ export function useQueuedPrompts({ reportError, t, }: UseQueuedPromptsArgs): UseQueuedPromptsResult { + const canQueryPendingPromptsRef = useRef(canQueryPendingPrompts); + canQueryPendingPromptsRef.current = canQueryPendingPrompts; const dispatchPolicyRef = useRef(getPromptDispatchError); dispatchPolicyRef.current = getPromptDispatchError; const sessionActions = useMemo( @@ -623,6 +629,8 @@ export function useQueuedPrompts({ streamingState !== 'idle' || sessionHasActivePrompt, ); const holdQueuedPromptsLocallyRef = useRef(holdQueuedPromptsLocally); + const hostHoldQueuedPromptsRef = useRef(holdQueuedPromptsLocally); + hostHoldQueuedPromptsRef.current = holdQueuedPromptsLocally; const refreshRequestSeqRef = useRef(0); /** * The tracked in-flight pending-prompts GET, if any. A caller that finds its @@ -745,8 +753,10 @@ export function useQueuedPrompts({ latestSessionIdRef.current = sessionId; latestWorkspaceCwdRef.current = workspaceCwd; latestConnectedRef.current = connected; - holdQueuedPromptsLocallyRef.current = holdQueuedPromptsLocally; const sessionActive = streamingState !== 'idle' || sessionHasActivePrompt; + const holdForLocalQueue = + holdQueuedPromptsLocally || (!canQueryPendingPrompts && sessionActive); + holdQueuedPromptsLocallyRef.current = holdForLocalQueue; useLayoutEffect(() => { midTurnReconcileSeqRef.current += 1; }, [sessionActive]); @@ -1250,7 +1260,11 @@ export function useQueuedPrompts({ // after this anchor. notBefore = refreshRequestSeqRef.current, ): Promise => { - if (!latestConnectedRef.current || !targetSessionId) + if ( + !canQueryPendingPromptsRef.current || + !latestConnectedRef.current || + !targetSessionId + ) return Promise.resolve({ status: 'skipped' }); if (latestSessionIdRef.current !== targetSessionId) return Promise.resolve({ status: 'skipped' }); @@ -1410,6 +1424,7 @@ export function useQueuedPrompts({ // The wait can outlive the session or the connection: re-apply // the entry guards before dispatching against them. if ( + !canQueryPendingPromptsRef.current || !latestConnectedRef.current || latestSessionIdRef.current !== targetSessionId ) { @@ -2259,6 +2274,73 @@ export function useQueuedPrompts({ let admissionStarted = false; let refreshedInBody = false; + if (!canQueryPendingPromptsRef.current) { + // Hosts without native queue routes use the same streaming send as + // the composer. The release chain waits for completion, not an + // invented daemon admission, before dispatching the next local row. + const removeLocalRow = () => { + const next = queuedPromptsRef.current.filter( + (row) => row.id !== localId, + ); + queuedPromptsRef.current = next; + setQueuedPrompts(next); + }; + return sessionActions + .sendPrompt(prompt.text, { + submittedPrompt: prompt.submittedPrompt, + images: prompt.images, + files: prompt.files, + inputAnnotations: prompt.inputAnnotations, + beforeDispatch: () => { + submitAbort.signal.throwIfAborted(); + if ( + !isCurrentOwnerTokenRef.current(ownerToken) || + latestSessionIdRef.current !== targetSessionId || + !queuedPromptsRef.current.some((row) => row.id === localId) + ) { + throw new PromptDispatchBlockedError( + 'Queued prompt owner changed', + ); + } + const error = dispatchPolicyRef.current?.(prompt.text); + if (error !== undefined) + throw new PromptDispatchBlockedError(error); + }, + onAdmissionStarted: () => { + admissionStarted = true; + }, + onAdmitted: () => { + if (!isCurrentOwnerTokenRef.current(ownerToken)) return; + removeLocalRow(); + prompt.onAdmitted?.(); + }, + }) + .then(() => { + if (!isCurrentOwnerTokenRef.current(ownerToken)) return; + removeLocalRow(); + prompt.onComplete?.(); + }) + .catch((error: unknown) => { + if (!isCurrentOwnerTokenRef.current(ownerToken)) return; + removeLocalRow(); + if (submitAbort.signal.aborted) return; + if ( + !admissionStarted && + !(error instanceof PromptDispatchBlockedError) + ) { + restoreQueuedPromptsToEditor([prompt], targetSessionId); + } + reportError(error, t('queue.queueFailed')); + }) + .finally(() => { + if ( + submitAbortControllersRef.current.get(localId) === submitAbort + ) { + submitAbortControllersRef.current.delete(localId); + } + }); + } + return sessionActions .submitPrompt(prompt.text, { ...(prompt.submittedPrompt !== undefined @@ -3076,7 +3158,7 @@ export function useQueuedPrompts({ canInjectMidTurnMedia && workspaceFileActions !== undefined; const shouldInsertMidTurn = - !holdQueuedPromptsLocallyRef.current && + !hostHoldQueuedPromptsRef.current && latestSessionActiveRef.current && (imageList.length === 0 || canSendMidTurnMedia) && (fileList.length === 0 || canSendMidTurnFiles) && @@ -3629,7 +3711,7 @@ export function useQueuedPrompts({ setQueuedPrompts(next); } } - if (holdQueuedPromptsLocally) return; + if (holdForLocalQueue) return; for (const prompt of queuedPromptsRef.current) { if (!prompt.midTurnFailedAction) continue; const next = queuedPromptsRef.current.filter( @@ -3714,7 +3796,7 @@ export function useQueuedPrompts({ }, [ sessionActive, writeBlocked, - holdQueuedPromptsLocally, + holdForLocalQueue, canQueryMidTurn, releaseChainedPrompt, submitPendingPrompt,