diff --git a/.qwen/skills/triage/references/pr-workflow.md b/.qwen/skills/triage/references/pr-workflow.md index 63160d753ed..b56ec8364fb 100644 --- a/.qwen/skills/triage/references/pr-workflow.md +++ b/.qwen/skills/triage/references/pr-workflow.md @@ -138,6 +138,7 @@ gh pr review "$PR_NUMBER" --repo "$REPO" --request-changes --body-file /tmp/stag ``` If the author cannot provide a reproduction on re-run, escalate to the maintainer (use `$QWEN_MAINTAINER_HANDLE` if set) and stop — do not proceed to Stage 2. + - **No reproduction = no fix.** A `fix:` PR without reproduction is a hypothesis — belongs in issues, not PRs. **"direction is correct" ≠ "problem exists."** If the runtime already handles the case correctly, there is no bug — only code hygiene. Code hygiene does not warrant a PR. diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 631b5586cf8..47e9dc0e277 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1354,10 +1354,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ].join(path.delimiter); try { - await expectAcpLocalReadRoots( - 'session-with-fs-env', - [...expectedDefaultAcpLocalReadRoots(), envRootA, envRootB], - ); + await expectAcpLocalReadRoots('session-with-fs-env', [ + ...expectedDefaultAcpLocalReadRoots(), + envRootA, + envRootB, + ]); } finally { restoreOptionalEnv(acpLocalReadRootsEnv, previousRoots); } diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 5f69e1be705..27ddf37b603 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -400,6 +400,58 @@ overflow: visible; } +.missingSessionState { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 24px; + color: var(--foreground); + font-family: var(--font-sans); +} + +.missingSessionMessage { + font-size: inherit; + font-weight: inherit; + line-height: 1.4; + text-align: center; +} + +.missingSessionButton { + min-width: 120px; + min-height: 38px; + padding: 0 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); + font: inherit; + font-size: 14px; + cursor: pointer; + transition: + border-color 120ms ease, + opacity 120ms ease; +} + +.missingSessionButton:hover, +.missingSessionButton:focus-visible { + border-color: color-mix(in srgb, var(--foreground) 24%, var(--border)); + opacity: 0.9; +} + +.missingSessionButton:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--foreground) 12%, transparent); + outline: none; +} + +.missingSessionButton:disabled { + cursor: not-allowed; + opacity: 0.5; +} + /* In-place panel that replaces the chat view (message list + composer) when Settings or Daemon Status is opened. Fills the chat pane and owns its own scroll container, mirroring what the DialogShell body used to provide. */ @@ -478,6 +530,14 @@ display: none; } +.chatSubtree { + display: contents; +} + +.chatSubtreeHidden { + display: none; +} + /* Empty (new-chat) state centers the welcome header + composer vertically via `.appChatEmpty .chatPane { justify-content: center }`. For that to take effect the wrapper must shrink to its content instead of filling the pane — the same diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index acf6d1be0ac..fc3c115905c 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'; type StreamingState = 'idle' | 'responding'; type MockConnection = { - status: 'connected'; + status: 'connected' | 'connecting' | 'disconnected' | 'error'; sessionId: string | undefined; clientId: string; displayName: string | undefined; @@ -18,6 +18,9 @@ type MockConnection = { capabilities: { qwenCodeVersion: string; features: string[] }; loadingTranscript: boolean; catchingUp: boolean; + error?: string; + errorStatus?: number; + missingSession?: boolean; }; type ChatEditorTestProps = { @@ -63,6 +66,9 @@ const { mockSessionActions: { sendPrompt: vi.fn().mockResolvedValue(undefined), createSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), + attachSession: vi.fn().mockResolvedValue(undefined), + closeSession: vi.fn().mockResolvedValue(undefined), + clearSession: vi.fn().mockResolvedValue(undefined), refreshCommands: vi.fn().mockResolvedValue(undefined), setModel: vi.fn().mockResolvedValue(undefined), setApprovalMode: vi.fn().mockResolvedValue(undefined), @@ -429,7 +435,11 @@ beforeEach(() => { }), }); mockConnection.sessionId = 'session-1'; + mockConnection.status = 'connected'; mockConnection.displayName = 'Session One'; + mockConnection.error = undefined; + mockConnection.errorStatus = undefined; + mockConnection.missingSession = false; mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; testState.prompt = 'hello'; @@ -451,6 +461,10 @@ beforeEach(() => { mockSessionActions.createSession.mockResolvedValue({ sessionId: 'session-1', }); + mockSessionActions.attachSession.mockResolvedValue(undefined); + mockSessionActions.closeSession.mockResolvedValue(undefined); + mockSessionActions.clearSession.mockResolvedValue(undefined); + mockSessionActions.loadSession.mockResolvedValue(undefined); mockSessionActions.refreshCommands.mockResolvedValue(undefined); mockSessionActions.setModel.mockResolvedValue(undefined); mockSessionActions.setApprovalMode.mockResolvedValue(undefined); @@ -482,6 +496,148 @@ afterEach(() => { }); describe('App session callbacks', () => { + it.each([404, 410])( + 'shows a missing-session empty state with a new-session action for %d', + async (status) => { + mockConnection.status = 'disconnected'; + mockConnection.sessionId = undefined; + mockConnection.error = 'Session load failed'; + mockConnection.errorStatus = status; + mockConnection.missingSession = true; + + const onSessionIdChange = vi.fn(); + const { container } = renderApp({ + onSessionIdChange, + }); + await flush(); + + expect(container.textContent).toContain('Current session does not exist'); + const submit = container.querySelector('[data-testid="submit"]'); + expect(submit?.closest('[class*="chatSubtreeHidden"]')).not.toBeNull(); + expect(onSessionIdChange).not.toHaveBeenCalledWith(undefined); + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'New session') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + expect(mockSessionActions.createSession).not.toHaveBeenCalled(); + expect(mockSessionActions.attachSession).not.toHaveBeenCalled(); + expect(onSessionIdChange).toHaveBeenCalledWith(undefined); + expect(onSessionIdChange).toHaveBeenCalledTimes(1); + }, + ); + + it('does not show missing-session state for non-404/410 errors', async () => { + mockConnection.status = 'disconnected'; + mockConnection.sessionId = undefined; + mockConnection.error = 'Server error'; + mockConnection.errorStatus = 500; + mockConnection.missingSession = false; + + const { container } = renderApp({ onSessionIdChange: vi.fn() }); + await flush(); + + expect(container.textContent).not.toContain( + 'Current session does not exist', + ); + }); + + it('does not show missing-session state while connecting', async () => { + mockConnection.status = 'connecting'; + mockConnection.sessionId = undefined; + mockConnection.error = 'Session load failed'; + mockConnection.errorStatus = 404; + mockConnection.missingSession = true; + + const { container } = renderApp({ onSessionIdChange: vi.fn() }); + await flush(); + + expect(container.textContent).not.toContain( + 'Current session does not exist', + ); + }); + + it('does not notify session change when missing-session new chat fails', async () => { + mockConnection.status = 'disconnected'; + mockConnection.sessionId = undefined; + mockConnection.error = 'Session load failed'; + mockConnection.errorStatus = 404; + mockConnection.missingSession = true; + mockSessionActions.clearSession.mockRejectedValueOnce(new Error('network')); + + const onSessionIdChange = vi.fn(); + const { container } = renderApp({ onSessionIdChange }); + await flush(); + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'New session') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + expect(onSessionIdChange).not.toHaveBeenCalled(); + }); + + it('preserves active goal for the same session and clears it after session changes', async () => { + const activeGoals: unknown[] = []; + const { rerender } = renderApp({ + renderFooter: (props) => { + activeGoals.push(props.activeGoal); + return null; + }, + }); + await flush(); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('web-shell-goal-status-active', { + detail: { + active: true, + condition: 'ship it', + setAt: 123, + }, + }), + ); + await Promise.resolve(); + }); + + expect(activeGoals.at(-1)).toMatchObject({ + condition: 'ship it', + setAt: 123, + }); + + mockConnection.errorStatus = 404; + rerender({ + renderFooter: (props) => { + activeGoals.push(props.activeGoal); + return null; + }, + }); + await flush(); + + expect(activeGoals.at(-1)).toMatchObject({ + condition: 'ship it', + setAt: 123, + }); + + mockConnection.sessionId = 'session-2'; + rerender({ + renderFooter: (props) => { + activeGoals.push(props.activeGoal); + return null; + }, + }); + await flush(); + + expect(activeGoals.at(-1)).toBeNull(); + }); + it('gates direct submissions and dispatches submit events with delayed sidebar reload', async () => { vi.useFakeTimers(); const onSubmitBefore = vi.fn().mockResolvedValue(undefined); @@ -705,7 +861,9 @@ describe('App session callbacks', () => { testState.prompt = '/settings'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); // A gated tool call arrives. await act(async () => { @@ -727,7 +885,9 @@ describe('App session callbacks', () => { testState.prompt = '/settings'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); await act(async () => { testState.blocks = [ @@ -753,7 +913,9 @@ describe('App session callbacks', () => { ?.click(); await Promise.resolve(); }); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); await act(async () => { testState.blocks = [makePendingPermissionBlock()]; @@ -821,7 +983,9 @@ describe('App session callbacks', () => { testState.prompt = '/settings'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); testState.prompt = '/schedule'; await clickSubmit(container); @@ -841,7 +1005,9 @@ describe('App session callbacks', () => { testState.prompt = '/settings'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); await act(async () => { testState.blocks = [makePendingPermissionBlock({ resolved: true })]; @@ -849,7 +1015,9 @@ describe('App session callbacks', () => { await Promise.resolve(); }); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); }); it('keeps the composer dormant (dialogOpen) while an approval overlay is up', async () => { @@ -881,7 +1049,9 @@ describe('App session callbacks', () => { testState.prompt = '/model'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="dialog-shell"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); await act(async () => { testState.blocks = [makePendingPermissionBlock()]; @@ -930,7 +1100,9 @@ describe('App session callbacks', () => { testState.prompt = '/settings'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); const sidebar = container.querySelector('[data-testid="sidebar"]'); await act(async () => { @@ -939,7 +1111,9 @@ describe('App session callbacks', () => { ); await Promise.resolve(); }); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); }); it('marks the composer dormant (dialogOpen) while a panel replaces the chat', async () => { @@ -985,7 +1159,9 @@ describe('App session callbacks', () => { testState.prompt = '/settings'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); editorFocus.mockClear(); await act(async () => { @@ -1012,7 +1188,9 @@ describe('App session callbacks', () => { ?.click(); await Promise.resolve(); }); - expect(container.querySelector('[data-testid="dialog-shell"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); await act(async () => { container @@ -1059,7 +1237,9 @@ describe('App session callbacks', () => { testState.prompt = '/settings'; await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); testState.prompt = '/resume session-2'; await clickSubmit(container); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index cdee556e241..bec1766c26d 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -970,6 +970,7 @@ export function App({ const btwAbortControllerRef = useRef(null); const currentSessionIdRef = useRef(connection.sessionId); const lastNotifiedSessionIdRef = useRef(undefined); + const lastGoalSessionIdRef = useRef(connection.sessionId); const displayMessages = useMemo(() => { const localMessages = [recapMessage].filter( (message): message is LocalAnchoredMessage => message !== null, @@ -1131,6 +1132,9 @@ export function App({ assignComposerRef(composerRef, editorRef.current ?? emptyComposerApi); }, [composerRef]); const [activeGoal, setActiveGoal] = useState(null); + const [isCreatingMissingSession, setIsCreatingMissingSession] = + useState(false); + const creatingMissingSessionRef = useRef(false); const activeGoalRef = useRef(null); activeGoalRef.current = activeGoal; const { @@ -2190,13 +2194,24 @@ export function App({ }, [connection.currentMode, connection.sessionId]); useEffect(() => { - if (connection.sessionId) { + const previousGoalSessionId = lastGoalSessionIdRef.current; + if ( + connection.sessionId && + connection.sessionId !== previousGoalSessionId + ) { setActiveGoal(null); } + lastGoalSessionIdRef.current = connection.sessionId; + if (!connection.sessionId && connection.missingSession) { + // Keep the dead-session route visible until the user explicitly starts a + // new chat; clearing it here would immediately hide the recovery state. + lastNotifiedSessionIdRef.current = connection.sessionId; + return; + } if (lastNotifiedSessionIdRef.current === connection.sessionId) return; lastNotifiedSessionIdRef.current = connection.sessionId; onSessionIdChange?.(connection.sessionId); - }, [connection.sessionId, onSessionIdChange]); + }, [connection.missingSession, connection.sessionId, onSessionIdChange]); const lastRenameSessionRef = useRef(undefined); const lastRenameNameRef = useRef(undefined); @@ -2400,6 +2415,21 @@ export function App({ return false; } }, [closeMobileDrawer, closePanel, reportError, sessionActions]); + const handleMissingSessionNewSession = useCallback(async () => { + if (creatingMissingSessionRef.current) return; + creatingMissingSessionRef.current = true; + setIsCreatingMissingSession(true); + setMainView('chat'); + try { + const success = await createNewSession(); + if (success) { + onSessionIdChange?.(undefined); + } + } finally { + creatingMissingSessionRef.current = false; + setIsCreatingMissingSession(false); + } + }, [createNewSession, onSessionIdChange]); const loadSidebarSession = useCallback( async (sessionId: string) => { @@ -3906,6 +3936,12 @@ export function App({ !showFloatingTodos && !pendingApproval && !btwMessage; + const missingSession = + connection.status !== 'connecting' && + !connection.sessionId && + connection.missingSession === true; + const showMissingSessionState = + missingSession && !activePanel && mainView === 'chat'; const effectiveChatWidthMode: ChatWidthMode = isChatEmptyState ? getDefaultChatWidthMode() : chatWidthMode; @@ -4434,241 +4470,266 @@ export function App({ // chat / hidden composer while a panel is shown. aria-hidden={activePanel ? true : undefined} > - - - +
+ {t('session.missing')} +
+ + + )} +
+ + + - 0 || + pendingApproval + ? styles.contentHasMessages + : undefined, + ] + .filter(Boolean) + .join(' ')} + > + + {btwMessage?.role === 'btw' && ( +
+ +
+ )} +
+
+
+ +
+ {canScrollMessageListToBottom && ( +
- {btwMessage?.role === 'btw' && ( -
- -
- )} -
- - - -
- {canScrollMessageListToBottom && ( -
- -
- )} - {showFloatingTodos && ( -
- -
- )} - {pendingToolApproval && ( -
-
+ )} + {showFloatingTodos && ( +
+ +
+ )} + {pendingToolApproval && ( +
+ +
+ )} + {pendingAskUserApproval && ( +
+ +
+ )} +
+ + {escapeHintVisible && streamingState === 'idle' && ( +
+ {t('editor.escClearHint')} +
+ )} + -
- )} - {pendingAskUserApproval && ( -
-
- )} -
- - {escapeHintVisible && streamingState === 'idle' && ( -
- {t('editor.escClearHint')} + {CustomFooter ? ( + 0 + ? (connection.tokenCount ?? 0) / + (connection.contextWindow ?? 0) + : 0 + } + activeGoal={activeGoal} + tasks={footerTasks} + availableModes={MODES_CYCLE} + availableModels={(connection.models ?? []) + .filter(isVisibleComposerModel) + .map((m) => ({ + id: m.id, + label: getModelDisplayName(m.label || m.id), + contextWindow: m.contextWindow, + }))} + skills={loadedSkills} + onSelectMode={handleSetMode} + onSelectModel={handleModelSelect} + /> + ) : ( + + setShowApprovalModeDialog((v) => !v) + } + onSelectModel={() => + setModelDialogMode((v) => (v ? null : 'main')) + } + onShowContext={() => + showContextUsage('/context', false) + } + onOpenSettings={() => openPanel('settings')} + ref={statusBarRef} + onOpenTasks={() => openTasksPanel()} + onReturnToInput={handleReturnToEditor} + tasks={backgroundTasks} + activeGoal={activeGoal} + hideSettings={hideSettings} + onToggleShortcuts={handleToggleShortcuts} + compact={true} + /> + )} + {isChatEmptyState && welcomeFooter && ( +
+ {welcomeFooter}
)} - -
- {CustomFooter ? ( - 0 - ? (connection.tokenCount ?? 0) / - (connection.contextWindow ?? 0) - : 0 - } - activeGoal={activeGoal} - tasks={footerTasks} - availableModes={MODES_CYCLE} - availableModels={(connection.models ?? []) - .filter(isVisibleComposerModel) - .map((m) => ({ - id: m.id, - label: getModelDisplayName(m.label || m.id), - contextWindow: m.contextWindow, - }))} - skills={loadedSkills} - onSelectMode={handleSetMode} - onSelectModel={handleModelSelect} - /> - ) : ( - - setShowApprovalModeDialog((v) => !v) - } - onSelectModel={() => - setModelDialogMode((v) => (v ? null : 'main')) - } - onShowContext={() => - showContextUsage('/context', false) - } - onOpenSettings={() => openPanel('settings')} - ref={statusBarRef} - onOpenTasks={() => openTasksPanel()} - onReturnToInput={handleReturnToEditor} - tasks={backgroundTasks} - activeGoal={activeGoal} - hideSettings={hideSettings} - onToggleShortcuts={handleToggleShortcuts} - compact={true} - /> - )} - {isChatEmptyState && welcomeFooter && ( -
- {welcomeFooter} -
- )} -
- + +
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 99109199f66..e5cbf866277 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -602,6 +602,8 @@ const EN: Messages = { 'quickActions.shellMode': 'Shell mode', 'quickActions.exitShellMode': 'Exit Shell', 'quickActions.setGoal': 'Set goal', + 'session.missing': 'Current session does not exist', + 'session.new': 'New session', // Scheduled tasks page 'scheduledTasks.title': 'Scheduled Tasks', 'scheduledTasks.subtitle': @@ -2168,6 +2170,8 @@ const ZH: Messages = { 'quickActions.shellMode': 'Shell模式', 'quickActions.exitShellMode': '退出Shell', 'quickActions.setGoal': '设置目标', + 'session.missing': '当前会话不存在', + 'session.new': '新建会话', // 定时任务页面 'scheduledTasks.title': '定时任务', 'scheduledTasks.subtitle': '按计划自动执行任务,也可随时手动触发。', diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index 871af542c52..a593b804c8d 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -164,6 +164,9 @@ export { /** Ordered list of approval modes for cycling: `['auto', 'suggest', 'ask']`. */ export { DAEMON_APPROVAL_MODES } from './daemon/index.js'; +/** HTTP statuses that mean the requested daemon session no longer exists. */ +export { isMissingSessionHttpStatus } from './daemon/index.js'; + /** Canonical Agent (sub-agent) tool name + predicate for permission UIs. */ export { AGENT_TOOL_NAME, isAgentTool } from './constants/toolNames.js'; diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts index 750aef524f4..3f9edd58119 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/webui/src/daemon/index.ts @@ -30,6 +30,7 @@ export { selectDaemonStreamingState, selectDaemonTodoLists, selectDaemonTranscriptStreamingState, + isMissingSessionHttpStatus, toDaemonPromptContent, } from './session/index.js'; export type { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index c9a041dfe4c..3a89994b880 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -2624,6 +2624,7 @@ describe('DaemonSessionProvider', () => { status: 'disconnected', error: 'missing session', }); + expect(connection?.missingSession).not.toBe(true); expect(connection?.sessionId).toBeUndefined(); const abortedPrompt = promptResult; if (!abortedPrompt) throw new Error('prompt was not started'); @@ -4050,6 +4051,7 @@ describe('DaemonSessionProvider', () => { status: 'disconnected', error: 'missing session', }); + expect(connection?.missingSession).not.toBe(true); expect(connection?.sessionId).toBeUndefined(); }); @@ -4965,7 +4967,7 @@ describe('DaemonSessionProvider', () => { }); await act(async () => { - await wait(10); + await wait(50); await flushPromises(); }); @@ -4976,6 +4978,264 @@ describe('DaemonSessionProvider', () => { }); }); + it('clears stale sessions on terminal HTTP heartbeat errors', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat'], + modelServices: [], + workspaceCwd: '/mock-workspace', + }); + const heartbeat = vi.fn(async () => { + throw Object.assign(new Error('session gone'), { status: 410 }); + }); + sdkMocks.sessions.push( + createMockSession({ + heartbeat, + events: createIdleEvents(), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + heartbeatIntervalMs: 20, + heartbeatFailureThreshold: 1, + }); + + await act(async () => { + await wait(50); + await flushPromises(); + }); + + expect(heartbeat).toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'disconnected', + error: 'session gone', + errorStatus: 410, + missingSession: true, + capabilities: { + workspaceCwd: '/mock-workspace', + features: ['client_heartbeat'], + }, + }); + expect(connection?.sessionId).toBeUndefined(); + }); + + it('uses recent HTTP status when heartbeat threshold ends with transport failure', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat'], + modelServices: [], + workspaceCwd: '/mock-workspace', + }); + const heartbeat = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('session gone'), { status: 410 }), + ) + .mockRejectedValue(new Error('heartbeat lost')); + sdkMocks.sessions.push( + createMockSession({ + heartbeat, + events: createIdleEvents(), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 2, + }); + + await act(async () => { + await wait(10); + await flushPromises(); + }); + + expect(heartbeat.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(connection).toMatchObject({ + status: 'disconnected', + error: 'session gone', + errorStatus: 410, + missingSession: true, + }); + expect(connection?.sessionId).toBeUndefined(); + }); + + it('preserves missing-session heartbeat status across later HTTP failures', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat'], + modelServices: [], + workspaceCwd: '/mock-workspace', + }); + const heartbeat = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('session gone'), { status: 410 }), + ) + .mockRejectedValue( + Object.assign(new Error('server error'), { status: 500 }), + ); + sdkMocks.sessions.push( + createMockSession({ + heartbeat, + events: createIdleEvents(), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 2, + }); + + await act(async () => { + await wait(10); + await flushPromises(); + }); + + expect(heartbeat.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(connection).toMatchObject({ + status: 'disconnected', + error: 'session gone', + errorStatus: 410, + missingSession: true, + }); + expect(connection?.sessionId).toBeUndefined(); + }); + + it('clears prompt state on terminal HTTP heartbeat errors', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat'], + modelServices: [], + workspaceCwd: '/mock-workspace', + }); + const heartbeat = vi.fn(async () => { + throw Object.assign(new Error('session gone'), { status: 410 }); + }); + const session = createMockSession({ + heartbeat, + submitPrompt: vi.fn( + (_req: unknown, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(createAbortError()), + { once: true }, + ); + }), + ), + events: createIdleEvents(), + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let streamingState: ReturnType = 'idle'; + + function Harness() { + actions = useDaemonActions(); + streamingState = useDaemonStreamingState(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 1, + }); + const providerActions = requireActions(actions); + + let promptResult: Promise | undefined; + await act(async () => { + promptResult = providerActions.sendPrompt('still running'); + await flushPromises(); + }); + + await act(async () => { + await wait(50); + await flushPromises(); + }); + + expect(streamingState).toBe('idle'); + const runningPrompt = promptResult; + if (!runningPrompt) throw new Error('prompt was not started'); + await expect(runningPrompt).resolves.toEqual({ + stopReason: 'cancelled', + }); + }); + + it.each([401, 403])( + 'enters auth-error state on %d heartbeat auth failures', + async (status) => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat'], + modelServices: [], + workspaceCwd: '/mock-workspace', + }); + const heartbeat = vi.fn(async () => { + throw Object.assign(new Error('Unauthorized'), { status }); + }); + sdkMocks.sessions.push( + createMockSession({ + heartbeat, + events: createIdleEvents(), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 1, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + + await act(async () => { + await wait(20); + await flushPromises(); + }); + + expect(heartbeat).toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'error', + error: 'Unauthorized', + errorStatus: status, + }); + expect(connection?.sessionId).toBeUndefined(); + }, + ); + it('ignores stale connect attempts after provider props change', async () => { const staleLoad = createDeferred(); const staleSession = createMockSession({ sessionId: 'session-a' }); @@ -5653,6 +5913,7 @@ describe('DaemonSessionProvider', () => { status: 'disconnected', error: 'session gone', }); + expect(connection?.missingSession).not.toBe(true); expect(connection?.sessionId).toBeUndefined(); await act(async () => { await expect(providerActions.cancel()).rejects.toThrow( @@ -5739,11 +6000,62 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ status: 'disconnected', error: 'session gone', + errorStatus: status, + missingSession: true, + capabilities: { + workspaceCwd: '/mock-workspace', + features: [], + }, }); expect(connection?.sessionId).toBeUndefined(); }, ); + it('clears missing-session state when starting a new session', async () => { + sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce( + Object.assign(new Error('session gone'), { status: 410 }), + ); + sdkMocks.sessions.push(createMockSession({ sessionId: 'new-session' })); + + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + sessionId: 'missing-session', + }); + + await act(async () => { + await flushPromises(); + }); + expect(connection).toMatchObject({ + status: 'disconnected', + error: 'session gone', + errorStatus: 410, + missingSession: true, + }); + + await act(async () => { + await actions?.newSession(); + await wait(5); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'new-session', + }); + expect(connection?.error).toBeUndefined(); + expect(connection?.errorStatus).toBeUndefined(); + expect(connection?.missingSession).not.toBe(true); + }); + it.each([401, 403])( 'preserves transcript and clears prompt state on %d auth failures from the SSE stream', async (status) => { @@ -5806,7 +6118,14 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ status: 'error', error: 'Unauthorized', + errorStatus: status, + missingSession: false, + capabilities: { + workspaceCwd: '/mock-workspace', + features: [], + }, }); + expect(connection?.sessionId).toBeUndefined(); expect(blocks[0]).toMatchObject({ kind: 'user', text: 'keep transcript', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 32050c2a004..b934755ecc0 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -73,6 +73,11 @@ import { isPendingPromptEvent, publishPendingPromptEvent, } from '../pendingPromptVersion.js'; +import { + MISSING_SESSION_HTTP_STATUSES, + isMissingSessionHttpStatus, + resolveConnectionErrorStatus, +} from './status.js'; import type { ActivePrompt, AddDaemonSessionNotice, @@ -148,7 +153,29 @@ const DaemonSessionNoticesContext = createContext< const DaemonWorkspaceEventSignalsContext = createContext< DaemonWorkspaceEventSignals | undefined >(undefined); -const TERMINAL_SESSION_HTTP_STATUSES = new Set([401, 403, 404, 410]); +/** + * Subset of TERMINAL_SESSION_HTTP_STATUSES that represent **credential + * failures** (vs session-not-found 404/410). Auth failures should NOT enter + * the reconnect loop even when `autoReconnect: true` — retrying with the + * same bad token loops forever, hammering the server with bad credentials + * and risking transcript wipes if reconnect later attaches a different + * session and hits the sessionId-change `store.reset()` branch. + * + * 404/410 (session-not-found) leave the requested session disconnected instead + * of silently creating a replacement empty session. + */ +const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]); +const TERMINAL_SESSION_HTTP_STATUSES = new Set([ + ...AUTH_FAILURE_HTTP_STATUSES, + ...MISSING_SESSION_HTTP_STATUSES, +]); + +interface HeartbeatFailureState { + sessionId?: string; + consecutiveFailures: number; + lastHttpError?: { status: number; message: string }; +} + // Keep enough transcript history for large daemon replay streams so event order // and subagent grouping survive replay. Rendering is virtualized, but message // normalization still rebuilds from retained blocks today, so this high default @@ -167,18 +194,6 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { authVersion: 0, }; -/** - * Subset of TERMINAL_SESSION_HTTP_STATUSES that represent **credential - * failures** (vs session-not-found 404/410). Auth failures should NOT enter - * the reconnect loop even when `autoReconnect: true` — retrying with the - * same bad token loops forever, hammering the server with bad credentials - * and risking transcript wipes if reconnect later attaches a different - * session and hits the sessionId-change `store.reset()` branch. - * - * 404/410 (session-not-found) leave the requested session disconnected instead - * of silently creating a replacement empty session. - */ -const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]); const UNHANDLED_SESSION = Symbol('unhandled session'); export function DaemonSessionProvider(props: DaemonSessionProviderProps) { @@ -242,6 +257,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ReturnType | undefined >(undefined); const heartbeatSupportedRef = useRef(false); + const heartbeatFailureStateRef = useRef({ + consecutiveFailures: 0, + }); const manualSessionClearRef = useRef(false); const skipNextCleanupDetachSessionIdRef = useRef( undefined, @@ -347,6 +365,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { let userDeletedSession = false; while (!disposed && !abort.signal.aborted) { + let loadingRequestedSession = false; try { // ── SSE Reconnection Strategy ──────────────────────────────── // @@ -399,6 +418,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connecting', error: undefined, + errorStatus: resolveConnectionErrorStatus( + undefined, + current.errorStatus, + ), })); const getWorkspaceCapabilities = workspaceGetCapabilitiesRef.current; @@ -484,11 +507,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const requestClientId = clientId ? clientIdRef.current : getStableClientId(undefined, targetSessionId); + loadingRequestedSession = Boolean(restoreSessionId); if (targetSessionId) { setConnection((current) => ({ ...current, sessionId: targetSessionId, error: undefined, + errorStatus: undefined, + missingSession: false, loadingTranscript: true, })); } @@ -521,6 +547,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }, requestClientId, ); + loadingRequestedSession = false; if (!clientId && nextSession.clientId) { clientIdRef.current = nextSession.clientId; persistStableClientId( @@ -908,6 +935,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connecting', error: undefined, + errorStatus: resolveConnectionErrorStatus( + undefined, + current.errorStatus, + ), })); }; for await (const event of activeSession.events({ @@ -1124,6 +1155,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connecting', error: undefined, + errorStatus: resolveConnectionErrorStatus( + undefined, + current.errorStatus, + ), })); break; } @@ -1182,6 +1217,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { status: 'disconnected', sessionId: undefined, error: undefined, + errorStatus: undefined, + missingSession: false, })); return; } @@ -1212,14 +1249,19 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } setConnection((current) => ({ ...current, - status: 'disconnected', - error: undefined, + status: current.status === 'error' ? 'error' : 'disconnected', + error: current.status === 'error' ? current.error : undefined, + errorStatus: resolveConnectionErrorStatus( + undefined, + current.errorStatus, + ), })); } } catch (error) { if (disposed || abort.signal.aborted) return; const message = error instanceof Error ? error.message : String(error); + const errorStatus = extractHttpStatus(error); const failedSessionId = session?.sessionId; const isAuthFailure = isAuthFailureHttpError(error); const isTerminal = isTerminalSessionHttpError(error); @@ -1258,14 +1300,45 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { session = undefined; sessionRef.current = undefined; if (isAuthFailure) { - setConnection({ status: 'error', error: message }); + setConnection((current) => ({ + ...current, + status: 'error', + sessionId: undefined, + error: message, + errorStatus: resolveConnectionErrorStatus( + errorStatus, + current.errorStatus, + ), + missingSession: false, + capabilities: capabilities ?? current.capabilities, + loadingTranscript: undefined, + catchingUp: undefined, + })); return; } + const missingLoadedSession = + loadingRequestedSession && + isMissingSessionHttpStatus(errorStatus); + console.warn( + '[DaemonSessionProvider] terminal session error (sessionId=%s, status=%d, message=%s)', + failedSessionId, + errorStatus, + message, + ); setConnection((current) => ({ ...current, status: 'disconnected', sessionId: undefined, error: message, + errorStatus: resolveConnectionErrorStatus( + errorStatus, + current.errorStatus, + ), + // SSE errors should not create the missing-session empty state, + // but they also must not clear one confirmed by load/heartbeat. + missingSession: + missingLoadedSession || current.missingSession === true, + capabilities: capabilities ?? current.capabilities, loadingTranscript: undefined, catchingUp: undefined, })); @@ -1286,16 +1359,27 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (!autoReconnect) { session = undefined; sessionRef.current = undefined; - setConnection({ + setConnection((current) => ({ + ...current, status: 'error', error: message, - }); + errorStatus: resolveConnectionErrorStatus( + errorStatus, + current.errorStatus, + ), + missingSession: false, + })); return; } setConnection((current) => ({ ...current, status: 'disconnected', error: message, + errorStatus: resolveConnectionErrorStatus( + errorStatus, + current.errorStatus, + ), + missingSession: false, loadingTranscript: undefined, })); } @@ -1393,8 +1477,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { return undefined; } + if (heartbeatFailureStateRef.current.sessionId !== connection.sessionId) { + heartbeatFailureStateRef.current = { + sessionId: connection.sessionId, + consecutiveFailures: 0, + }; + } + const heartbeatFailureState = heartbeatFailureStateRef.current; let disposed = false; - let consecutiveFailures = 0; const timer = setInterval(() => { const session = sessionRef.current; if (!session) return; @@ -1402,24 +1492,100 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { .heartbeat() .then(() => { if (disposed) return; - if (consecutiveFailures >= heartbeatFailureThreshold) { + if ( + heartbeatFailureState.consecutiveFailures >= + heartbeatFailureThreshold + ) { setConnection((current) => current.sessionId === session.sessionId - ? { ...current, status: 'connected', error: undefined } + ? { + ...current, + status: 'connected', + error: undefined, + errorStatus: undefined, + } : current, ); } - consecutiveFailures = 0; + heartbeatFailureState.consecutiveFailures = 0; + heartbeatFailureState.lastHttpError = undefined; }) .catch((error: unknown) => { if (disposed) return; - consecutiveFailures += 1; - if (consecutiveFailures < heartbeatFailureThreshold) return; + heartbeatFailureState.consecutiveFailures += 1; const message = error instanceof Error ? error.message : 'Session heartbeat failed'; + const thisErrorStatus = extractHttpStatus(error); + if (thisErrorStatus !== undefined) { + const lastStatus = heartbeatFailureState.lastHttpError?.status; + heartbeatFailureState.lastHttpError = { + status: + resolveConnectionErrorStatus(thisErrorStatus, lastStatus) ?? + thisErrorStatus, + message: isMissingSessionHttpStatus(lastStatus) + ? (heartbeatFailureState.lastHttpError?.message ?? message) + : message, + }; + } + if ( + heartbeatFailureState.consecutiveFailures < + heartbeatFailureThreshold + ) { + return; + } + const errorStatus = heartbeatFailureState.lastHttpError?.status; + const effectiveMessage = + heartbeatFailureState.lastHttpError?.message ?? message; + const authFailure = + errorStatus !== undefined && + AUTH_FAILURE_HTTP_STATUSES.has(errorStatus); + const missingSession = isMissingSessionHttpStatus(errorStatus); + if (authFailure || missingSession) { + const deadSessionId = session.sessionId; + if (missingSession) { + console.warn( + '[DaemonSessionProvider] heartbeat detected missing session (sessionId=%s, status=%d)', + deadSessionId, + errorStatus, + ); + } else { + console.warn( + '[DaemonSessionProvider] heartbeat auth failure (sessionId=%s, status=%d)', + deadSessionId, + errorStatus, + ); + } + const active = activePromptsRef.current.get(deadSessionId); + active?.controller.abort(); + activePromptsRef.current.delete(deadSessionId); + clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); + setPromptStatus('idle'); + if (sessionRef.current?.sessionId === deadSessionId) { + if (missingSession) { + manualSessionClearRef.current = true; + } + sessionRef.current = undefined; + } + } setConnection((current) => current.sessionId === session.sessionId - ? { ...current, status: 'disconnected', error: message } + ? { + ...current, + status: authFailure ? 'error' : 'disconnected', + error: effectiveMessage, + errorStatus: resolveConnectionErrorStatus( + errorStatus, + current.errorStatus, + ), + missingSession, + ...(authFailure || missingSession + ? { + sessionId: undefined, + loadingTranscript: undefined, + catchingUp: undefined, + } + : {}), + } : current, ); }); diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 01c090b6b43..ef096612b0a 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -28,6 +28,8 @@ describe('getConnectionAfterSessionClear', () => { loadingTranscript: true, catchingUp: true, error: 'old error', + errorStatus: 404, + missingSession: true, } as DaemonConnectionState, 'session-a', ); @@ -38,6 +40,8 @@ describe('getConnectionAfterSessionClear', () => { loadingTranscript: undefined, catchingUp: undefined, error: undefined, + errorStatus: undefined, + missingSession: false, }); expect(next).not.toHaveProperty('sessionId'); expect(next).not.toHaveProperty('clientId'); diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index efa47f22994..0986f867082 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -101,6 +101,8 @@ export function getConnectionAfterSessionClear( loadingTranscript: undefined, catchingUp: undefined, error: undefined, + errorStatus: undefined, + missingSession: false, }; } @@ -229,6 +231,8 @@ export function createDaemonSessionActions({ clientId: undefined, displayName: undefined, error: undefined, + errorStatus: undefined, + missingSession: false, loadingTranscript: true, catchingUp: undefined, })); @@ -617,6 +621,8 @@ export function createDaemonSessionActions({ ...(nextSession.clientId ? { clientId: nextSession.clientId } : {}), workspaceCwd: nextSession.workspaceCwd, error: undefined, + errorStatus: undefined, + missingSession: false, })); return nextSession; } catch (error) { @@ -661,6 +667,12 @@ export function createDaemonSessionActions({ async newSession() { manualSessionClearRef.current = false; clearActiveSessionState(); + setConnection((current) => ({ + ...current, + missingSession: false, + error: undefined, + errorStatus: undefined, + })); setNewSessionNonce((nonce) => nonce + 1); }, diff --git a/packages/webui/src/daemon/session/index.ts b/packages/webui/src/daemon/session/index.ts index 87c8fefd19b..1721d62cb4f 100644 --- a/packages/webui/src/daemon/session/index.ts +++ b/packages/webui/src/daemon/session/index.ts @@ -60,3 +60,4 @@ export { } from './selectors.js'; export type { DaemonStreamingState } from './selectors.js'; export { toDaemonPromptContent } from './promptContent.js'; +export { isMissingSessionHttpStatus } from './status.js'; diff --git a/packages/webui/src/daemon/session/status.test.ts b/packages/webui/src/daemon/session/status.test.ts new file mode 100644 index 00000000000..51e5f519b31 --- /dev/null +++ b/packages/webui/src/daemon/session/status.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + isMissingSessionHttpStatus, + resolveConnectionErrorStatus, +} from './status.js'; + +describe('daemon session status helpers', () => { + it('detects missing-session HTTP statuses', () => { + expect(isMissingSessionHttpStatus(404)).toBe(true); + expect(isMissingSessionHttpStatus(410)).toBe(true); + expect(isMissingSessionHttpStatus(401)).toBe(false); + expect(isMissingSessionHttpStatus(undefined)).toBe(false); + }); + + it('preserves missing-session status across status-less retries', () => { + expect(resolveConnectionErrorStatus(undefined, 404)).toBe(404); + expect(resolveConnectionErrorStatus(undefined, 410)).toBe(410); + expect(resolveConnectionErrorStatus(undefined, 500)).toBeUndefined(); + }); + + it('uses the next status when one is available', () => { + expect(resolveConnectionErrorStatus(500, undefined)).toBe(500); + expect(resolveConnectionErrorStatus(404, undefined)).toBe(404); + }); + + it('keeps missing-session status over later non-missing statuses', () => { + expect(resolveConnectionErrorStatus(502, 404)).toBe(404); + expect(resolveConnectionErrorStatus(503, 410)).toBe(410); + }); +}); diff --git a/packages/webui/src/daemon/session/status.ts b/packages/webui/src/daemon/session/status.ts new file mode 100644 index 00000000000..61506c47483 --- /dev/null +++ b/packages/webui/src/daemon/session/status.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const MISSING_SESSION_HTTP_STATUSES = [404, 410] as const; + +const MISSING_SESSION_HTTP_STATUS_SET = new Set( + MISSING_SESSION_HTTP_STATUSES, +); + +export function isMissingSessionHttpStatus( + status: number | undefined, +): boolean { + return status !== undefined && MISSING_SESSION_HTTP_STATUS_SET.has(status); +} + +/** + * Preserve 404/410 after heartbeat detects a missing session so a later + * status-less transport retry cannot hide the missing-session empty state. + */ +export function resolveConnectionErrorStatus( + nextStatus: number | undefined, + currentStatus: number | undefined, +): number | undefined { + if ( + isMissingSessionHttpStatus(currentStatus) && + !isMissingSessionHttpStatus(nextStatus) + ) { + return currentStatus; + } + return nextStatus; +} diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index ef8a0a7f389..e420cd31c8d 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -77,6 +77,10 @@ export interface DaemonConnectionState { /** True while replaying buffered events after a reconnect. */ catchingUp?: boolean; error?: string; + /** Latest HTTP error status kept for diagnostics; use missingSession for UI. */ + errorStatus?: number; + /** True only when the server confirmed the current session is missing. */ + missingSession?: boolean; } export interface DaemonTokenUsage {