diff --git a/docs/design/2026-08-10-transactional-webui-session-switching.md b/docs/design/2026-08-10-transactional-webui-session-switching.md new file mode 100644 index 00000000000..06ab2e95949 --- /dev/null +++ b/docs/design/2026-08-10-transactional-webui-session-switching.md @@ -0,0 +1,37 @@ +# Transactional cross-session switching + +## Problem + +The WebUI historically detached the current session, stopped its event stream, and cleared its transcript before a target `loadSession` or `resumeSession` completed. A slow or failed restore therefore left the user without the still-healthy source session. The WebShell also keyed its main provider by the requested session, so controlled navigation remounted the provider before the target was usable. + +## Scope + +This change makes only cross-logical-session load and resume transactional. A logical target is the normalized `(sessionId, workspaceCwd)` pair. Initial bootstrap, same-logical reload, client-id replacement, full resync, memory repair, and branch adoption retain their existing behavior and are follow-up work. + +Modern transactional behavior requires a successful capability snapshot that advertises `client_identity` and concrete client IDs for both attachments. A daemon that explicitly lacks the feature retains the legacy destructive path. Unknown capabilities or malformed modern responses fail closed and preserve the source. + +## Coordinator + +Each provider owns one raw restore slot and one desired intent. Equivalent requests coalesce. A newer target rejects the prior public intent and replaces the queued intent, while an already-running SDK request continues to settlement because it is not cancellable. Its result is adopted only when it still matches the latest target; otherwise its attachment is detached once on a best-effort basis. The queued deadline begins when the caller requests the switch, so an expired target never starts a restore. + +Commit is guarded by the desired intent, absolute deadline, provider environment, local lifecycle, source logical identity, and restored target identity. Timeout, SDK failure, supersede, staging failure, and commit are explicit competing terminal states rather than an implicit `Promise.race`. + +## Staging and commit + +Replay is normalized into an unsubscribed shadow transcript store in batches of at most 512 events. The compacted replay and live journal arrays are traversed directly and are not concatenated. Only bounded summaries of notices and side-channel events are retained. Staging never writes the visible transcript, connection, prompt maps, notices, or workspace signals. + +After the final guard succeeds, one synchronous commit flushes the source runner's legal buffered events, stops its stream, installs the target transcript/history/session/workspace/client and connection ref, notifies the WebShell wrapper, publishes staged side effects, and settles source-local prompt waiters. The public load promise resolves only after those synchronous owners agree. Target metadata and SSE start afterward without a second restore. Source detach is asynchronous, single-attempt, and never blocks the public result or the next restore. + +## WebShell ownership + +For modern daemons, the main workspace wrapper keeps one provider instance and separates the desired target from the committed target. Workspace resolution and restore failures continue rendering the committed source. A synchronous commit callback advances wrapper ownership before the public promise resolves. Stable failed targets are latched so unrelated renders do not retry them; a controlled failure rolls the host back only while the failed desired generation is still current. + +Session transition state gates new prompt and mutation entry points while preserving the source event stream, existing prompt completion, cancellation, permissions, and read-only controls. UI navigation uses an invocation token plus an attachment-identity snapshot so stale completion handlers cannot clear or focus a newer request. Session-owned worktree, branch, git intent, and recap state are not cleared until ownership commits. + +## Compatibility and risks + +Legacy daemons keep the old keyed/destructive behavior. Cleanup is deliberately best effort: a failed detach can leave an invisible client reference until the existing reaper runs. Staging temporarily holds the source transcript and target replay at once, and CPU-heavy restore work in a shared ACP child can still delay source events. This change does not optimize JSONL reading, selective replay, or daemon capacity. + +## Verification + +Unit coverage exercises delayed success/failure, exact-target coalescing, latest-only serialization, controlled switching, malformed ownership, write gating, synchronous commit ownership, source events during preparation, wrapper remount compatibility, workspace resolution failure, invocation fencing, and post-commit catch-up timeout behavior. A focused JSDOM/real-daemon test delays delivery of an already-completed target restore response and verifies that the source remains usable until atomic commit; a structured 504 must leave the source intact. diff --git a/integration-tests/cli/qwen-serve-webui-session-switching.test.ts b/integration-tests/cli/qwen-serve-webui-session-switching.test.ts new file mode 100644 index 00000000000..1e2c8d7d5fb --- /dev/null +++ b/integration-tests/cli/qwen-serve-webui-session-switching.test.ts @@ -0,0 +1,334 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { act, createElement } from 'react'; +import type { Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { + DaemonHttpError, + type DaemonTranscriptBlock, +} from '@qwen-code/sdk/daemon'; +import { + makeTempWorkspace, + spawnDaemon, + type SpawnedDaemon, +} from './_daemon-harness.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MOCK_AGENT_PATH = path.resolve( + __dirname, + '../fixtures/mock-acp-child/agent.mjs', +); + +let activeDaemon: SpawnedDaemon | undefined; +let root: Root | undefined; +let dom: JSDOM; +let createRoot: typeof import('react-dom/client').createRoot; +let DaemonSessionProvider: typeof import('@qwen-code/webui/daemon-react-sdk').DaemonSessionProvider; +let useActions: typeof import('@qwen-code/webui/daemon-react-sdk').useActions; +let useConnection: typeof import('@qwen-code/webui/daemon-react-sdk').useConnection; +let useTranscriptBlocks: typeof import('@qwen-code/webui/daemon-react-sdk').useTranscriptBlocks; +const originalGlobalDescriptors = new Map( + ['window', 'document', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'].map( + (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const, + ), +); + +beforeAll(async () => { + dom = new JSDOM('', { + url: 'http://localhost', + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: dom.window, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: dom.window.document, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: dom.window.navigator, + }); + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); + ({ createRoot } = await import('react-dom/client')); + ({ DaemonSessionProvider, useActions, useConnection, useTranscriptBlocks } = + await import('@qwen-code/webui/daemon-react-sdk')); +}); + +afterAll(() => { + dom.window.close(); + for (const [key, descriptor] of originalGlobalDescriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +afterEach(async () => { + if (root) { + await act(async () => root?.unmount()); + root = undefined; + } + await activeDaemon?.dispose(); + activeDaemon = undefined; +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function waitFor( + condition: () => boolean, + description: string, + timeoutMs = 8_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } + throw new Error(`Timed out waiting for ${description}`); +} + +describe('qwen serve WebUI transactional session switching', () => { + async function setup() { + const workspace = makeTempWorkspace('webui-session-switching'); + activeDaemon = await spawnDaemon({ + workspaceCwd: workspace, + env: { + QWEN_CLI_ENTRY: MOCK_AGENT_PATH, + MOCK_ACP_MODE: 'echo', + }, + }); + const source = await activeDaemon.client.createOrAttachSession({ + sessionScope: 'thread', + }); + const resolvedWorkspace = source.workspaceCwd ?? workspace; + await activeDaemon.client.prompt(source.sessionId, { + prompt: [{ type: 'text', text: 'source transcript' }], + }); + const target = await activeDaemon.client.createOrAttachSession({ + sessionScope: 'thread', + }); + await activeDaemon.client.prompt(target.sessionId, { + prompt: [{ type: 'text', text: 'target transcript' }], + }); + let actions: ReturnType | undefined; + let connection: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + function Harness() { + actions = useActions(); + connection = useConnection(); + blocks = useTranscriptBlocks(); + return null; + } + const container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + createElement( + DaemonSessionProvider, + { + autoConnect: true, + baseUrl: activeDaemon!.base, + token: activeDaemon!.token, + sessionId: source.sessionId, + workspaceCwd: resolvedWorkspace, + }, + createElement(Harness), + ), + ); + }); + await waitFor( + () => + connection?.status === 'connected' && + connection.sessionId === source.sessionId && + connection.capabilities?.features.includes('client_identity') === + true && + JSON.stringify(blocks).includes('source transcript'), + 'source session bootstrap', + ); + return { + workspace: resolvedWorkspace, + source, + target, + getActions: () => { + if (!actions) throw new Error('session actions unavailable'); + return actions; + }, + getConnection: () => connection, + getBlocks: () => blocks, + }; + } + + it('keeps the source usable until a completed target response is released', async () => { + const originalFetch = globalThis.fetch; + const state = await setup(); + const responseReady = deferred(); + const releaseResponse = deferred(); + let loadOutcome: Promise | undefined; + try { + globalThis.fetch = async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + const response = await originalFetch(request); + if ( + request.method === 'POST' && + new URL(request.url).pathname.endsWith( + `/session/${encodeURIComponent(state.target.sessionId)}/load`, + ) + ) { + responseReady.resolve(); + await releaseResponse.promise; + } + return response; + }; + act(() => { + loadOutcome = state + .getActions() + .loadSession(state.target.sessionId, { + workspaceCwd: state.workspace, + }) + .then( + () => undefined, + (error: unknown) => error, + ); + }); + await responseReady.promise; + + expect(state.getConnection()).toMatchObject({ + status: 'connected', + sessionId: state.source.sessionId, + sessionTransition: { phase: 'preparing' }, + }); + await expect(state.getActions().cancel()).resolves.toBeUndefined(); + await activeDaemon!.client.prompt(state.source.sessionId, { + prompt: [{ type: 'text', text: 'source remains live' }], + }); + await waitFor( + () => JSON.stringify(state.getBlocks()).includes('source remains live'), + 'source event while target response is held', + ); + + await act(async () => { + releaseResponse.resolve(); + expect(await loadOutcome).toBeUndefined(); + }); + expect(state.getConnection()).toMatchObject({ + status: 'connected', + sessionId: state.target.sessionId, + }); + expect(JSON.stringify(state.getBlocks())).toContain('target transcript'); + expect(JSON.stringify(state.getBlocks())).not.toContain( + 'source remains live', + ); + } finally { + globalThis.fetch = originalFetch; + releaseResponse.resolve(); + await loadOutcome?.catch(() => undefined); + if (root) { + await act(async () => root?.unmount()); + root = undefined; + } + await activeDaemon?.dispose(); + activeDaemon = undefined; + fs.rmSync(state.workspace, { recursive: true, force: true }); + } + }, 30_000); + + it('preserves the source after a structured target timeout', async () => { + const originalFetch = globalThis.fetch; + const state = await setup(); + try { + globalThis.fetch = async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + if ( + request.method === 'POST' && + new URL(request.url).pathname.endsWith( + `/session/${encodeURIComponent(state.target.sessionId)}/load`, + ) + ) { + return new Response( + JSON.stringify({ + code: 'session_restore_timeout', + error: 'Session restore timed out', + retryable: true, + }), + { + status: 504, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '5', + }, + }, + ); + } + return originalFetch(request); + }; + let restoreError: unknown; + await act(async () => { + try { + await state.getActions().loadSession(state.target.sessionId, { + workspaceCwd: state.workspace, + }); + } catch (error) { + restoreError = error; + } + }); + expect(restoreError).toBeInstanceOf(DaemonHttpError); + expect(restoreError).toMatchObject({ + status: 504, + body: { + code: 'session_restore_timeout', + retryable: true, + }, + }); + expect(state.getConnection()).toMatchObject({ + status: 'connected', + sessionId: state.source.sessionId, + sessionTransition: { + phase: 'failed', + error: { code: 'session_restore_timeout', status: 504 }, + }, + }); + expect(JSON.stringify(state.getBlocks())).toContain('source transcript'); + await activeDaemon!.client.prompt(state.source.sessionId, { + prompt: [{ type: 'text', text: 'source after timeout' }], + }); + await waitFor( + () => + JSON.stringify(state.getBlocks()).includes('source after timeout'), + 'source event after target timeout', + ); + } finally { + globalThis.fetch = originalFetch; + if (root) { + await act(async () => root?.unmount()); + root = undefined; + } + await activeDaemon?.dispose(); + activeDaemon = undefined; + fs.rmSync(state.workspace, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index bd86103ff34..e740f8b24f2 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -274,6 +274,7 @@ const { listScheduledTasks: vi.fn(), updateScheduledTask: vi.fn(), deleteScheduledTask: vi.fn(), + deleteModel: vi.fn().mockResolvedValue(undefined), }, mockMcp: { initialize: vi.fn().mockResolvedValue({ accepted: true }), @@ -301,6 +302,7 @@ const { onDismissFollowup: vi.fn(), }, testState: { + ownerVersion: 0, prompt: 'hello', inputAnnotations: undefined as DaemonInputAnnotation[] | undefined, promptImages: undefined as @@ -315,11 +317,22 @@ const { latestStatusBarTasks: null as DaemonSessionMonitorTaskStatus[] | null, latestStatusBarOnOpenTasks: null as (() => void) | null, latestMessageListProps: null as { + messages?: Array<{ + role?: string; + content?: string; + answer?: string; + isPending?: boolean; + }>; failedPromptMessageId?: string; onRetryFailedPrompt?: () => void; isResponding?: boolean; activeTurnStartedAt?: number; } | null, + latestBtwMessageProps: null as { + question: string; + answer: string; + isPending: boolean; + } | null, latestAddWorkspaceDialogProps: null as AddWorkspaceDialogTestProps | null, latestToolApprovalKeyboardActive: null as boolean | null, toolApprovalKeyboardActiveHistory: [] as Array, @@ -352,6 +365,15 @@ const { latestSettingsState: null as { settings: DaemonSettingDescriptor[]; } | null, + latestModelManagement: null as { + busy?: boolean; + onSelectModel?: (modelId: string) => void; + onDeleteModel?: (target: { + authType: string; + modelId: string; + baseUrl?: string; + }) => void; + } | null, latestScheduledTasksProps: null as { onRunPrompt?: ( prompt: string, @@ -392,51 +414,60 @@ const { }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ - DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'], - DaemonSessionProvider: ({ children }: { children: ReactNode }) => children, - useActions: () => mockSessionActions, - useConnection: () => mockConnection, - useDaemonFollowupSuggestion: () => ({ - followupState: null, - clear: mockFollowup.clear, - onAcceptFollowup: mockFollowup.onAcceptFollowup, - onDismissFollowup: mockFollowup.onDismissFollowup, - }), - useSessionNotices: () => ({ notices: [], dismissNotice: vi.fn() }), - usePromptStatus: () => 'idle', - useSettings: () => ({ - settings: testState.settings, - setValue: settingsSetValue, - reload: settingsReload, - loading: false, - }), - useProviders: () => ({ - providers: [], - current: undefined, - loading: false, - error: undefined, - reload: vi.fn().mockResolvedValue(undefined), - }), - useStreamingState: () => testState.streamingState, - useTranscriptBlocks: () => testState.blocks, - useTranscriptHistory: () => ({ - hasMore: false, - loading: false, - capacityReached: false, - paginationError: false, - loadMore: vi.fn(), - release: vi.fn(), - }), - useTranscriptStore: () => mockStore, - useWorkspace: () => mockWorkspace, - useWorkspaceActions: () => mockWorkspaceActions, - useMcp: () => mockMcp, - useWorkspaceEventSignals: () => ({ - artifactsVersion: 0, - extensionsVersion: 0, - }), -})); +vi.mock('@qwen-code/webui/daemon-react-sdk', () => { + const ownerGuard = { + capture: () => { + const ownerVersion = testState.ownerVersion; + return { isCurrent: () => testState.ownerVersion === ownerVersion }; + }, + }; + return { + DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'], + DaemonSessionProvider: ({ children }: { children: ReactNode }) => children, + useActions: () => mockSessionActions, + useConnection: () => mockConnection, + useDaemonSessionOwnerGuard: () => ownerGuard, + useDaemonFollowupSuggestion: () => ({ + followupState: null, + clear: mockFollowup.clear, + onAcceptFollowup: mockFollowup.onAcceptFollowup, + onDismissFollowup: mockFollowup.onDismissFollowup, + }), + useSessionNotices: () => ({ notices: [], dismissNotice: vi.fn() }), + usePromptStatus: () => 'idle', + useSettings: () => ({ + settings: testState.settings, + setValue: settingsSetValue, + reload: settingsReload, + loading: false, + }), + useProviders: () => ({ + providers: [], + current: undefined, + loading: false, + error: undefined, + reload: vi.fn().mockResolvedValue(undefined), + }), + useStreamingState: () => testState.streamingState, + useTranscriptBlocks: () => testState.blocks, + useTranscriptHistory: () => ({ + hasMore: false, + loading: false, + capacityReached: false, + paginationError: false, + loadMore: vi.fn(), + release: vi.fn(), + }), + useTranscriptStore: () => mockStore, + useWorkspace: () => mockWorkspace, + useWorkspaceActions: () => mockWorkspaceActions, + useMcp: () => mockMcp, + useWorkspaceEventSignals: () => ({ + artifactsVersion: 0, + extensionsVersion: 0, + }), + }; +}); vi.mock('@qwen-code/sdk/daemon', () => ({ DaemonHttpError: class DaemonHttpError extends Error { @@ -636,6 +667,12 @@ vi.mock('./components/MessageList', async () => { return { MessageList: React.forwardRef(function MessageList( props: { + messages?: Array<{ + role?: string; + content?: string; + answer?: string; + isPending?: boolean; + }>; showRetryHint?: boolean; onRetryClick?: () => void; failedPromptMessageId?: string; @@ -695,8 +732,18 @@ vi.mock('./components/messages/SettingsMessage', async () => { language: string, scope: 'user' | 'workspace', ) => void; + modelManagement?: { + busy?: boolean; + onSelectModel?: (modelId: string) => void; + onDeleteModel?: (target: { + authType: string; + modelId: string; + baseUrl?: string; + }) => void; + }; }) => { testState.latestSettingsState = props.settingsState; + testState.latestModelManagement = props.modelManagement ?? null; return React.createElement( 'div', { 'data-testid': 'settings-message' }, @@ -1449,7 +1496,19 @@ vi.doMock('./monitorDetailsContext', async () => { }, }; }); -mockComponent('./components/messages/BtwMessage', 'BtwMessage'); +vi.doMock('./components/messages/BtwMessage', async () => { + const React = await import('react'); + return { + BtwMessage: (props: { + question: string; + answer: string; + isPending: boolean; + }) => { + testState.latestBtwMessageProps = props; + return React.createElement('div'); + }, + }; +}); mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay'); const { @@ -4347,6 +4406,7 @@ beforeEach(() => { }; mockConnection.gitBranch = undefined; mockConnection.gitStatus = undefined; + testState.ownerVersion = 0; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; @@ -4397,6 +4457,7 @@ beforeEach(() => { testState.latestStatusBarTasks = null; testState.latestStatusBarOnOpenTasks = null; testState.latestMessageListProps = null; + testState.latestBtwMessageProps = null; testState.latestAddWorkspaceDialogProps = null; testState.latestToolApprovalKeyboardActive = null; testState.toolApprovalKeyboardActiveHistory = []; @@ -4412,6 +4473,7 @@ beforeEach(() => { testState.latestMonitorDetailsOnOpen = null; testState.settings = []; testState.latestSettingsState = null; + testState.latestModelManagement = null; testState.latestScheduledTasksProps = null; testState.latestGoalsProps = null; rawEnqueuePrompt.mockClear(); @@ -4515,6 +4577,8 @@ beforeEach(() => { mockWorkspaceActions.listScheduledTasks.mockReset(); mockWorkspaceActions.updateScheduledTask.mockReset(); mockWorkspaceActions.deleteScheduledTask.mockReset(); + mockWorkspaceActions.deleteModel.mockReset(); + mockWorkspaceActions.deleteModel.mockResolvedValue(undefined); mockMcp.initialize.mockClear(); mockMcp.initialize.mockResolvedValue({ accepted: true }); mockMcp.reloadConfig.mockClear(); @@ -6939,6 +7003,59 @@ describe('App session callbacks', () => { }); }); + it('does not expose cached metadata after a same-id workspace switch', async () => { + mockConnection.displayName = undefined; + const sourceStatus = deferred<{ + workspaceCwd: string; + displayName: string; + }>(); + mockWorkspace.client.sessionStatus.mockReturnValueOnce( + sourceStatus.promise, + ); + const sourceList = deferred(); + mockWorkspace.client.listWorkspaceSessions.mockReturnValueOnce( + sourceList.promise, + ); + const targetStatus = deferred<{ workspaceCwd: string }>(); + mockWorkspace.client.sessionStatus.mockReturnValueOnce( + targetStatus.promise, + ); + const { container, rerender } = renderApp(); + + await act(async () => { + sourceStatus.resolve({ + workspaceCwd: '/work/a', + displayName: 'Session A title', + }); + sourceList.resolve([]); + await Promise.all([sourceStatus.promise, sourceList.promise]); + }); + + await vi.waitFor(() => { + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Session A title'); + }); + + testState.ownerVersion += 1; + mockConnection.workspaceCwd = '/work/b'; + rerender(); + await flush(); + rerender(); + + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).not.toContain('Session A title'); + + await act(async () => { + targetStatus.resolve({ workspaceCwd: '/work/b' }); + await targetStatus.promise; + }); + await flush(); + }); + it('keeps the persistent chat header opt-in for existing integrations', () => { const { container } = renderApp({ header: undefined }); @@ -9633,6 +9750,9 @@ describe('App session callbacks', () => { it('discards an automatic recap after switching to an existing session', async () => { const { recap, container } = await triggerAutoRecap(); + mockSessionActions.loadSession.mockImplementationOnce(async () => { + testState.ownerVersion += 1; + }); await act(async () => { container .querySelector('[data-testid="load-session"]') @@ -9652,8 +9772,36 @@ describe('App session callbacks', () => { ]); }); + it('keeps an automatic recap when an existing-session switch fails', async () => { + const { recap } = await triggerAutoRecap(); + mockSessionActions.loadSession.mockRejectedValueOnce( + new Error('target restore failed'), + ); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('qwen:open-session', { detail: 'session-2' }), + ); + await Promise.resolve(); + }); + await act(async () => { + recap.resolve({ sessionId: 'session-1', recap: 'Current session recap' }); + await recap.promise; + }); + + expect(mockStore.dispatch).toHaveBeenCalledWith([ + expect.objectContaining({ + source: 'recap', + text: expect.stringContaining('Current session recap'), + }), + ]); + }); + it('discards an automatic recap after resuming a session by command', async () => { const { recap } = await triggerAutoRecap(); + mockSessionActions.loadSession.mockImplementationOnce(async () => { + testState.ownerVersion += 1; + }); await act(async () => { testState.latestChatEditorProps?.onSubmit('/resume session-3'); recap.resolve({ @@ -9663,7 +9811,9 @@ describe('App session callbacks', () => { await recap.promise; }); - expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-3'); + expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-3', { + workspaceCwd: undefined, + }); expect(mockStore.dispatch).not.toHaveBeenCalledWith([ expect.objectContaining({ source: 'recap' }), ]); @@ -9766,6 +9916,49 @@ describe('App session callbacks', () => { expect(editorFocus).toHaveBeenCalledOnce(); }); + it('does not finish a same-id workspace switch before commit', async () => { + const load = deferred(); + mockSessionActions.loadSession.mockImplementationOnce(() => { + mockConnection.sessionTransition = { + phase: 'preparing', + operation: 'load', + origin: 'action', + targetSessionId: 'session-1', + targetWorkspaceCwd: '/work/b', + }; + return load.promise; + }); + const { rerender } = renderApp(); + await flush(); + editorFocus.mockClear(); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('qwen:open-session', { + detail: { sessionId: 'session-1', workspaceCwd: '/work/b' }, + }), + ); + await Promise.resolve(); + }); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + expect(editorFocus).not.toHaveBeenCalled(); + expect(testState.latestChatEditorProps?.disabled).toBe(true); + expect(testState.latestChatEditorProps?.onSubmit('must stay on A')).toBe( + false, + ); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + await act(async () => { + mockConnection.workspaceCwd = '/work/b'; + mockConnection.sessionTransition = undefined; + load.resolve(); + rerender(); + await load.promise; + }); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + expect(editorFocus).toHaveBeenCalledOnce(); + }); + it('opens a Live session in its owning Conversations workspace', async () => { renderApp(); await flush(); @@ -11114,6 +11307,97 @@ describe('App session callbacks', () => { }); }); + it('preserves the turn-error retry while session writes are blocked', async () => { + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = 'recover this stream'; + await clickSubmit(container); + mockSessionActions.sendPrompt.mockClear(); + act(() => { + testState.blocks = [ + { + kind: 'error', + source: 'turn_error', + id: 'turn-error-switching', + errorKind: 'model_stream_interrupted', + text: 'terminated', + }, + ]; + rerender({ desiredSessionTargetPending: true }); + }); + + const retry = container.querySelector( + '[data-testid="retry"]', + ); + expect(retry).not.toBeNull(); + act(() => retry?.click()); + + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="retry"]')).not.toBeNull(); + + act(() => rerender({ desiredSessionTargetPending: false })); + await flush(); + const unblockedRetry = container.querySelector( + '[data-testid="retry"]', + ); + expect(unblockedRetry).not.toBeNull(); + act(() => unblockedRetry?.click()); + await flush(); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce(); + }); + + it('does not settle a turn-error retry into a different workspace', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const retrySend = deferred(); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = 'recover this stream'; + await clickSubmit(container); + mockSessionActions.sendPrompt.mockClear(); + act(() => { + testState.blocks = [ + { + kind: 'error', + source: 'turn_error', + id: 'turn-error-cross-workspace', + errorKind: 'model_stream_interrupted', + text: 'terminated', + }, + ]; + rerender(); + }); + mockSessionActions.sendPrompt.mockReturnValueOnce(retrySend.promise); + + await act(async () => { + container + .querySelector('[data-testid="retry"]') + ?.click(); + await Promise.resolve(); + }); + const retryOptions = mockSessionActions.sendPrompt.mock.calls[0]?.[1]; + act(() => { + retryOptions?.onAdmissionStarted?.(); + mockConnection.workspaceCwd = '/other-workspace'; + testState.ownerVersion += 1; + rerender(); + }); + await act(async () => { + retrySend.reject(new Error('response lost')); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="prompt-admission-unknown"]'), + ).toBeNull(); + expect(warn).not.toHaveBeenCalledWith( + '[WebShell] post-turn retry admission outcome is unknown', + expect.anything(), + ); + warn.mockRestore(); + }); + it('locks an image retry when its admission response is lost', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const retrySend = deferred(); @@ -11335,6 +11619,65 @@ describe('App session callbacks', () => { expect(container.querySelector('button[title="Side task"]')).toBeNull(); }); + it('settles visible recap after a same-id attachment replacement', async () => { + const recap = deferred<{ sessionId: string; recap: string | null }>(); + mockSessionActions.recapSession.mockReturnValueOnce(recap.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/recap'; + await clickSubmit(container); + expect( + testState.latestMessageListProps?.messages?.some((message) => + message.content?.includes('Generating recap'), + ), + ).toBe(true); + + act(() => { + testState.ownerVersion += 1; + rerender(); + }); + await act(async () => { + recap.resolve({ sessionId: 'session-1', recap: 'Reconnect-safe recap' }); + await recap.promise; + }); + + expect( + testState.latestMessageListProps?.messages?.some((message) => + message.content?.includes('Reconnect-safe recap'), + ), + ).toBe(true); + }); + + it('settles visible btw after a same-id attachment replacement', async () => { + const btw = deferred<{ answer: string }>(); + mockSessionActions.btwSession.mockReturnValueOnce(btw.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/btw keep this answer'; + await clickSubmit(container); + expect(testState.latestBtwMessageProps).toMatchObject({ + question: 'keep this answer', + isPending: true, + }); + + act(() => { + testState.ownerVersion += 1; + rerender(); + }); + await act(async () => { + btw.resolve({ answer: 'Reconnect-safe answer' }); + await btw.promise; + }); + + expect(testState.latestBtwMessageProps).toMatchObject({ + question: 'keep this answer', + answer: 'Reconnect-safe answer', + isPending: false, + }); + }); + it('opens a new side task for /btw side when the capability is available', async () => { mockConnection.capabilities.features = ['session_side_task']; const { container } = renderApp(); @@ -11534,6 +11877,95 @@ describe('App session callbacks', () => { ); }); + it('does not send a deferred plan prompt into a replacement owner', async () => { + const approval = deferred(); + mockSessionActions.setApprovalMode.mockReturnValueOnce(approval.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/plan explain the migration'; + await clickSubmit(container); + expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('plan'); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-2'; + rerender(); + }); + await act(async () => { + approval.resolve(); + await approval.promise; + }); + + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(testState.latestChatEditorProps?.isPreparing).toBe(false); + }); + + it('clears deferred plan preparation after a same-session reattach', async () => { + const approval = deferred(); + mockSessionActions.setApprovalMode.mockReturnValueOnce(approval.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/plan explain the migration'; + await clickSubmit(container); + expect(testState.latestChatEditorProps?.isPreparing).toBe(true); + + act(() => { + testState.ownerVersion += 1; + rerender(); + }); + await act(async () => { + approval.resolve(); + await approval.promise; + }); + + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(testState.latestChatEditorProps?.isPreparing).toBe(false); + }); + + it('does not let an A-to-B-to-A plan completion clear newer preparation', async () => { + const firstApproval = deferred(); + const secondApproval = deferred(); + mockSessionActions.setApprovalMode + .mockReturnValueOnce(firstApproval.promise) + .mockReturnValueOnce(secondApproval.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/plan first'; + await clickSubmit(container); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-2'; + rerender(); + }); + await flush(); + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-1'; + rerender(); + }); + await flush(); + + testState.prompt = '/plan second'; + await clickSubmit(container); + expect(testState.latestChatEditorProps?.isPreparing).toBe(true); + + await act(async () => { + firstApproval.resolve(); + await firstApproval.promise; + }); + expect(testState.latestChatEditorProps?.isPreparing).toBe(true); + + await act(async () => { + secondApproval.resolve(); + await secondApproval.promise; + }); + expect(testState.latestChatEditorProps?.isPreparing).toBe(false); + }); + it('dispatches turn_complete only for the session that was streaming', async () => { const onSessionChange = vi.fn(); const { container, rerender } = renderApp({ onSessionChange }); @@ -14298,6 +14730,124 @@ describe('App session callbacks', () => { expect(settingsReload).toHaveBeenCalled(); }); + it('clears model selection busy state after a same-session reattach', async () => { + const selection = deferred(); + mockSessionActions.setModel.mockReturnValueOnce(selection.promise); + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + + act(() => testState.latestModelManagement?.onSelectModel?.('qwen-next')); + expect(testState.latestModelManagement?.busy).toBe(true); + + act(() => { + testState.ownerVersion += 1; + rerender(); + }); + await act(async () => { + selection.resolve(); + await selection.promise; + }); + + expect(testState.latestModelManagement?.busy).toBe(false); + }); + + it('does not let an A-to-B-to-A model completion clear a newer selection', async () => { + const firstSelection = deferred(); + const secondSelection = deferred(); + mockSessionActions.setModel + .mockReturnValueOnce(firstSelection.promise) + .mockReturnValueOnce(secondSelection.promise); + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + act(() => testState.latestModelManagement?.onSelectModel?.('model-a')); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-2'; + rerender(); + }); + await flush(); + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-1'; + rerender(); + }); + await flush(); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + act(() => testState.latestModelManagement?.onSelectModel?.('model-b')); + expect(testState.latestModelManagement?.busy).toBe(true); + + await act(async () => { + firstSelection.resolve(); + await firstSelection.promise; + }); + expect(testState.latestModelManagement?.busy).toBe(true); + + await act(async () => { + secondSelection.resolve(); + await secondSelection.promise; + }); + expect(testState.latestModelManagement?.busy).toBe(false); + }); + + it('does not let an A-to-B-to-A deletion clear a newer selection', async () => { + const deletion = deferred(); + const selection = deferred(); + mockWorkspaceActions.deleteModel.mockReturnValueOnce(deletion.promise); + mockSessionActions.setModel.mockReturnValueOnce(selection.promise); + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + act(() => + testState.latestModelManagement?.onDeleteModel?.({ + authType: 'api-key', + modelId: 'old-model', + }), + ); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-2'; + rerender(); + }); + await flush(); + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-1'; + rerender(); + }); + await flush(); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + act(() => testState.latestModelManagement?.onSelectModel?.('model-b')); + expect(testState.latestModelManagement?.busy).toBe(true); + + await act(async () => { + deletion.resolve(undefined); + await deletion.promise; + }); + expect(testState.latestModelManagement?.busy).toBe(true); + + await act(async () => { + selection.resolve(); + await selection.promise; + }); + expect(testState.latestModelManagement?.busy).toBe(false); + }); + it('sends /model --fast with --global when the fast-model picker is opened from the User tab', async () => { const { container } = renderApp(); await flush(); @@ -14481,7 +15031,9 @@ describe('App session callbacks', () => { await flush(); expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); - expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-2'); + expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-2', { + workspaceCwd: undefined, + }); }); it('dispatches rename only after the current session name changes', async () => { @@ -14606,6 +15158,40 @@ describe('App session callbacks', () => { }); }); + it('reconciles a confirmed rename after its source attachment is replaced', async () => { + const rename = deferred(); + mockSessionActions.renameSession.mockReturnValueOnce(rename.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/rename Delayed title'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.renameSession).toHaveBeenCalledWith( + 'Delayed title', + ); + }); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-2'; + mockConnection.workspaceCwd = '/tmp/other'; + rerender(); + }); + sessionCatalogController.renamed.mockClear(); + + await act(async () => { + rename.resolve(); + await rename.promise; + }); + + expect(sessionCatalogController.renamed).toHaveBeenCalledWith( + '/tmp/project', + 'session-1', + 'Delayed title', + ); + }); + it('reconciles a name reused after the session loaded a different title', async () => { const { container, rerender } = renderApp(); await flush(); @@ -14668,8 +15254,9 @@ describe('App prompt send failure retry', () => { it('keeps an unknown lazy-session admission scoped to its allocated session', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); mockConnection.sessionId = undefined; - mockSessionActions.createSession.mockResolvedValueOnce({ - sessionId: 'session-created', + mockSessionActions.createSession.mockImplementationOnce(async () => { + testState.ownerVersion += 1; + return { sessionId: 'session-created' }; }); const firstSend = deferred(); mockSessionActions.sendPrompt.mockReturnValueOnce(firstSend.promise); @@ -14687,8 +15274,8 @@ describe('App prompt send failure retry', () => { expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce(); }); const firstSendOptions = mockSessionActions.sendPrompt.mock.calls[0]?.[1]; - act(() => firstSendOptions?.onAdmissionStarted?.()); act(() => { + firstSendOptions?.onAdmissionStarted?.(); mockConnection.sessionId = 'session-created'; rerender(); }); @@ -14964,6 +15551,67 @@ describe('App prompt send failure retry', () => { }); }); + it('settles a prompt retry after a same-id attachment replacement', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const firstSend = deferred(); + const retrySend = deferred(); + let retryAdmitted: (() => void) | undefined; + mockSessionActions.sendPrompt + .mockImplementationOnce(() => { + testState.blocks = [{ id: 'u1', kind: 'user' }]; + return firstSend.promise; + }) + .mockImplementationOnce( + ( + _text: string, + options?: { + onAdmitted?: () => void; + }, + ) => { + retryAdmitted = options?.onAdmitted; + return retrySend.promise; + }, + ); + const { container, rerender } = renderApp(); + await flush(); + + act(() => { + testState.latestChatEditorProps?.onSubmit('hello'); + }); + testState.messages = [{ id: 'u1', role: 'user', content: 'hello' }]; + await act(async () => { + firstSend.reject(new DaemonHttpError(413, {}, 'Prompt too large')); + await Promise.resolve(); + }); + act(() => + container + .querySelector('[data-testid="failed-prompt-retry"]') + ?.click(), + ); + + act(() => { + testState.ownerVersion += 1; + rerender(); + retryAdmitted?.(); + }); + await act(async () => { + retrySend.resolve(); + await retrySend.promise; + testState.streamingState = 'idle'; + rerender(); + await Promise.resolve(); + }); + act(() => { + testState.streamingState = 'responding'; + rerender(); + }); + + expect(testState.latestMessageListProps?.isResponding).toBe(true); + expect( + container.querySelector('[data-testid="streaming-status"]'), + ).not.toBeNull(); + }); + it('shows processing only after retry admission and restarts its timer', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const firstSend = deferred(); @@ -15664,22 +16312,103 @@ describe('App manual-run orchestration (scheduled tasks)', () => { void second; }); - it('rejects a bound run when the session switch times out', async () => { + it('does not let an old same-target failure clear a newer bound run', async () => { + admitOnSend(); + const { container, rerender } = renderApp(); + await flush(); + const run = await openRunHandler(container); + const firstRestore = deferred(); + const secondRestore = deferred(); + mockSessionActions.loadSession + .mockReturnValueOnce(firstRestore.promise) + .mockReturnValueOnce(secondRestore.promise); + let firstError: unknown; + let secondSettled = false; + + await act(async () => { + void run('first', 'same-target').catch((error) => { + firstError = error; + }); + void run('second', 'same-target').then( + () => { + secondSettled = true; + }, + () => { + secondSettled = true; + }, + ); + await Promise.resolve(); + }); + expect((firstError as Error | undefined)?.message).toMatch(/superseded/); + + await act(async () => { + firstRestore.reject(new Error('old restore failed')); + await Promise.resolve(); + }); + expect(secondSettled).toBe(false); + + mockConnection.sessionId = 'same-target'; + await act(async () => { + secondRestore.resolve(); + rerender(); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(secondSettled).toBe(true)); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + 'second', + expect.any(Object), + ); + }); + + it('does not apply the catch-up timeout while restore is pending', async () => { const { container } = renderApp(); await flush(); const run = await openRunHandler(container); + const restore = deferred(); + mockSessionActions.loadSession.mockReturnValueOnce(restore.promise); vi.useFakeTimers(); let err: unknown; await act(async () => { void run('do the thing', 'never-active').catch((e) => { err = e; }); - await Promise.resolve(); // loadSidebarSession resolves; no fire (not current) + await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(30_000); }); - expect((err as Error | undefined)?.message).toMatch(/Timed out switching/); + expect(err).toBeUndefined(); + await act(async () => { + restore.reject(new Error('restore timed out')); + await Promise.resolve(); + }); + expect((err as Error | undefined)?.message).toBe('restore timed out'); + }); + + it('starts the 30 second timeout only after commit while catching up', async () => { + const { container } = renderApp(); + await flush(); + const run = await openRunHandler(container); + mockSessionActions.loadSession.mockImplementationOnce(async () => { + mockConnection.sessionId = 'bound-session'; + mockConnection.catchingUp = true; + }); + vi.useFakeTimers(); + let err: unknown; + await act(async () => { + void run('do the thing', 'bound-session').catch((error) => { + err = error; + }); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(29_999); + }); + expect(err).toBeUndefined(); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect((err as Error | undefined)?.message).toMatch(/session replay/); }); it('"create via chat" starts a fresh session and primes the composer', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 0ba3bcac005..78a2d240b8e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -21,6 +21,7 @@ import { useSettings, useProviders, useSessionNotices, + useDaemonSessionOwnerGuard, useStreamingState, useTranscriptHistory, useTranscriptStore, @@ -29,6 +30,7 @@ import { useWorkspaceEventSignals, type DaemonSessionActions, type DaemonSessionNotice, + type DaemonSessionOwnerSnapshot, type DaemonStreamingState, } from '@qwen-code/webui/daemon-react-sdk'; import { DaemonHttpError, isDaemonTurnError } from '@qwen-code/sdk/daemon'; @@ -442,9 +444,6 @@ interface ArtifactPanelSessionState { interface PaneArtifactSnapshot { artifacts: readonly DaemonSessionArtifact[]; } -// Cap on how long a manual "run now" waits for its bound session to become -// active before giving up, so the scheduled-tasks UI can't stay stuck disabled -// if the switch never completes. const BOUND_RUN_SWITCH_TIMEOUT_MS = 30_000; function availableSkillInfos(status: { @@ -645,6 +644,7 @@ export type WebShellSlashCommandHandler = ( ) => boolean | void; export interface WebShellProps { + desiredSessionTargetPending?: boolean; /** Called whenever the attached daemon session or workspace changes. */ onSessionIdChange?: ( sessionId: string | undefined, @@ -1566,6 +1566,7 @@ function readScopedModelSetting( } export function App({ + desiredSessionTargetPending = false, onSessionIdChange, onSessionCreated, theme: providedTheme, @@ -1872,6 +1873,16 @@ export function App({ const store = useTranscriptStore(); const blocks = useAnimationFrameTranscriptBlocks(); const connection = useConnection(); + const logicalSessionKey = connection.sessionId + ? `${connection.workspaceCwd ?? ''}\0${connection.sessionId}` + : undefined; + const sessionWriteBlocked = + desiredSessionTargetPending || + connection.sessionTransition?.phase === 'queued' || + connection.sessionTransition?.phase === 'preparing'; + const sessionWriteBlockedRef = useRef(sessionWriteBlocked); + sessionWriteBlockedRef.current = sessionWriteBlocked; + const sessionOwnerGuard = useDaemonSessionOwnerGuard(); const transcriptHistory = useTranscriptHistory(); const workspace = useWorkspace(); const sessionCatalogController = useSessionCatalogController( @@ -2030,12 +2041,17 @@ export function App({ const [sessionStatusDisplayName, setSessionStatusDisplayName] = useState< string | undefined >(undefined); - // Tracks the session id from the latest effect run. In-flight fetches - // compare their captured sid against this ref on resolve: a match means + // Tracks the logical session from the latest effect run. In-flight fetches + // compare their captured key against this ref on resolve: a match means // the response is still relevant and may set OR clear the worktree state; // a mismatch means connection.sessionId moved on (reconnect cycling or a // user-initiated switch) and the stale response is dropped. - const worktreeSessionIdRef = useRef(undefined); + const worktreeSessionKeyRef = useRef(undefined); + useLayoutEffect(() => { + setSessionWorktree(undefined); + setSessionBranch(undefined); + setSessionStatusDisplayName(undefined); + }, [logicalSessionKey]); // Restore worktree info from the server when switching to an existing // session. The effect intentionally does NOT cancel in-flight fetches on // cleanup: connection.sessionId can cycle through several sessions during @@ -2043,19 +2059,15 @@ export function App({ // discard the one response we actually need. useEffect(() => { const sid = connection.sessionId; - const previousSid = worktreeSessionIdRef.current; - worktreeSessionIdRef.current = sid; + const sessionKey = logicalSessionKey; + const owner = sessionOwnerGuard.capture(); + worktreeSessionKeyRef.current = sessionKey; if (!sid) { setSessionWorktree(undefined); setSessionBranch(undefined); setSessionStatusDisplayName(undefined); return; } - if (previousSid !== sid) { - setSessionWorktree(undefined); - setSessionBranch(undefined); - setSessionStatusDisplayName(undefined); - } if ( connection.status !== 'connected' || connection.loadingTranscript || @@ -2066,7 +2078,7 @@ export function App({ workspace.client .sessionStatus(sid) .then((summary) => { - if (worktreeSessionIdRef.current === sid) { + if (worktreeSessionKeyRef.current === sessionKey && owner.isCurrent()) { setSessionWorktree(summary.worktree); setSessionBranch(summary.branch); setSessionStatusDisplayName(summary.displayName); @@ -2081,7 +2093,12 @@ export function App({ { fresh: true }, ) .then((page) => { - if (worktreeSessionIdRef.current !== sid) return; + if ( + worktreeSessionKeyRef.current !== sessionKey || + !owner.isCurrent() + ) { + return; + } const listedSession = page.sessions.find( (session) => session.sessionId === sid, ); @@ -2092,7 +2109,7 @@ export function App({ .catch(() => undefined); }) .catch(() => { - if (worktreeSessionIdRef.current === sid) { + if (worktreeSessionKeyRef.current === sessionKey && owner.isCurrent()) { setSessionWorktree(undefined); setSessionBranch(undefined); setSessionStatusDisplayName(undefined); @@ -2100,9 +2117,13 @@ export function App({ }); }, [ connection.catchingUp, + connection.clientId, connection.loadingTranscript, connection.sessionId, connection.status, + connection.workspaceCwd, + logicalSessionKey, + sessionOwnerGuard, workspace.client, ]); // Active workspace: the connected session's workspace, else the workspace @@ -2260,6 +2281,11 @@ export function App({ failedPromptRef.current = next; setFailedPrompt(next); }, []); + useLayoutEffect(() => { + updateFailedPrompt(null); + setFailedPromptRetry(null); + updateUnknownPromptAdmission(null); + }, [logicalSessionKey, updateFailedPrompt, updateUnknownPromptAdmission]); const [recapMessage, setRecapMessage] = useState( null, ); @@ -2274,7 +2300,6 @@ export function App({ const lastNotifiedSessionIdRef = useRef(undefined); const lastNotifiedWorkspaceIdRef = useRef(undefined); const lastNotifiedWorkspaceCwdRef = useRef(undefined); - const lastGoalSessionIdRef = useRef(connection.sessionId); const displayMessages = useMemo(() => { const localMessages = [recapMessage].filter( (message): message is LocalAnchoredMessage => message !== null, @@ -2482,7 +2507,7 @@ export function App({ useLayoutEffect(() => { preserveEnvironmentPanelOnArtifactOpenRef.current = false; setEnvironmentPanelOpen(false); - }, [connection.sessionId]); + }, [logicalSessionKey]); const artifactPanelOpenRef = useRef(artifactPanelOpen); artifactPanelOpenRef.current = artifactPanelOpen; const [activeArtifactPanelTabId, setActiveArtifactPanelTabId] = useState< @@ -2525,7 +2550,7 @@ export function App({ const artifactPanelStateBySessionRef = useRef( new Map(), ); - const artifactPanelSessionIdRef = useRef(connection.sessionId); + const artifactPanelSessionIdRef = useRef(logicalSessionKey); artifactPanelSessionStateRef.current = { open: artifactPanelOpen, tabs: artifactPanelTabs, @@ -2558,7 +2583,7 @@ export function App({ } } - const nextSessionId = connection.sessionId; + const nextSessionId = logicalSessionKey; artifactPanelSessionIdRef.current = nextSessionId; const savedState = nextSessionId ? artifactPanelStateBySessionRef.current.get(nextSessionId) @@ -2585,7 +2610,7 @@ export function App({ setPaneArtifactSnapshots(new Map()); setArtifactPanelWidth(savedState.width); setArtifactPanelFullscreen(false); - }, [connection.sessionId]); + }, [logicalSessionKey]); const sideTasksAvailable = Boolean(connection.sessionId && connection.workspaceCwd) && connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) === @@ -2594,6 +2619,9 @@ export function App({ items: [], loaded: false, }); + useLayoutEffect(() => { + setSideTaskCatalog({ items: [], loaded: false }); + }, [logicalSessionKey]); const optimisticSideTaskIdsRef = useRef(new Set()); const visibleSideTasks = sideTaskCatalog.parentSessionId === connection.sessionId @@ -3550,9 +3578,11 @@ export function App({ async (tool: ACPToolCall): Promise => { const sessionId = monitorDetailsSessionIdRef.current; if (!sessionId) return false; + const owner = sessionOwnerGuard.capture(); try { const snapshot = await sessionActions.getTasks(); if ( + !owner.isCurrent() || monitorDetailsSessionIdRef.current !== sessionId || snapshot.sessionId !== sessionId ) { @@ -3567,7 +3597,7 @@ export function App({ return false; } }, - [openMonitorPanel, sessionActions], + [openMonitorPanel, sessionActions, sessionOwnerGuard], ); useEffect(() => { const monitors = new Map( @@ -3672,6 +3702,7 @@ export function App({ assignComposerRef(composerRef, editorRef.current ?? emptyComposerApi); }, [composerRef]); const [activeGoal, setActiveGoal] = useState(null); + useLayoutEffect(() => setActiveGoal(null), [logicalSessionKey]); const [isCreatingMissingSession, setIsCreatingMissingSession] = useState(false); const creatingMissingSessionRef = useRef(false); @@ -4552,6 +4583,7 @@ export function App({ const refreshActiveSessionDisplayName = useCallback(async () => { const activeConnection = connectionRef.current; if (!activeConnection.sessionId || !activeConnection.workspaceCwd) return; + const owner = sessionOwnerGuard.capture(); try { const page = await loadSessionCatalogOnce( workspace.client, @@ -4563,6 +4595,7 @@ export function App({ { fresh: true }, ); if ( + !owner.isCurrent() || connectionRef.current.sessionId !== activeConnection.sessionId || connectionRef.current.workspaceCwd !== activeConnection.workspaceCwd || connectionRef.current.displayName @@ -4576,7 +4609,7 @@ export function App({ } catch { // The live session_metadata_updated event remains the primary path. } - }, [workspace.client]); + }, [sessionOwnerGuard, workspace.client]); const refreshActiveSessionDisplayNameRef = useRef( refreshActiveSessionDisplayName, ); @@ -4648,6 +4681,11 @@ export function App({ setCurrentMode(modeId); }, []); const [isPreparingPrompt, setIsPreparingPrompt] = useState(false); + const planPreparationTokenRef = useRef(0); + useLayoutEffect(() => { + planPreparationTokenRef.current += 1; + setIsPreparingPrompt(false); + }, [logicalSessionKey]); const createSessionPromiseRef = useRef | null>( null, ); @@ -4844,8 +4882,15 @@ export function App({ onAdmissionStarted?: (sessionId: string | undefined) => void; onAdmitted?: () => void; onOptimisticUserMessage?: (message: OptimisticUserMessage) => void; + ownerRef?: { current: DaemonSessionOwnerSnapshot }; }, ) => { + if (sessionWriteBlockedRef.current) { + throw new DOMException( + 'Session switch is still preparing', + 'InvalidStateError', + ); + } const isUserPrompt = !text.trimStart().startsWith('/'); const previousLastSubmittedPrompt = lastSubmittedPromptRef.current; const previousLastSubmittedImages = lastSubmittedImagesRef.current; @@ -4918,6 +4963,7 @@ export function App({ let allocatedSessionId: string | undefined; try { allocatedSessionId = await ensureSessionForPrompt(); + if (opts?.ownerRef) opts.ownerRef.current = sessionOwnerGuard.capture(); } finally { if (shouldShowPreparing) { setIsPreparingPrompt(false); @@ -5010,6 +5056,7 @@ export function App({ getComposerWorkspaceCwd, sessionCatalogController, sessionActions, + sessionOwnerGuard, store, ], ); @@ -5308,7 +5355,9 @@ export function App({ discardUnknownQueuedPrompt, } = useQueuedPrompts({ connected, + writeBlocked: sessionWriteBlocked, sessionId: connection.sessionId, + workspaceCwd: connection.workspaceCwd, clientId: connection.clientId, canMutateMidTurn, canQueryMidTurn, @@ -5426,14 +5475,16 @@ export function App({ setBtwMessage(null); setTasksDialogMessage(null); lastRecapBlockCountRef.current = 0; - }, [connection.sessionId]); + }, [connection.sessionId, connection.workspaceCwd]); const runVisibleRecap = useCallback(() => { + if (sessionWriteBlocked) return; if (!requireActiveSessionForLocalCommand()) return; const messageId = `local-recap-${nextRecapMessageIdRef.current++}`; const anchorIndex = messages.length; const anchorAfterId = messages.at(-1)?.id; const sessionId = connection.sessionId; + const workspaceCwd = connection.workspaceCwd; setRecapMessage({ anchorAfterId, anchorIndex, @@ -5447,7 +5498,11 @@ export function App({ }); sessionActions.recapSession().then( (result) => { - if (currentSessionIdRef.current !== sessionId) return; + if ( + currentSessionIdRef.current !== sessionId || + connectionRef.current.workspaceCwd !== workspaceCwd + ) + return; setRecapMessage({ anchorAfterId, anchorIndex, @@ -5463,7 +5518,11 @@ export function App({ }); }, (error: unknown) => { - if (currentSessionIdRef.current !== sessionId) return; + if ( + currentSessionIdRef.current !== sessionId || + connectionRef.current.workspaceCwd !== workspaceCwd + ) + return; setRecapMessage(null); if (!isAbortError(error) && !isAlreadyDispatched(error)) { console.warn('[web-shell] unhandled recap failure', error); @@ -5472,14 +5531,17 @@ export function App({ ); }, [ connection.sessionId, + connection.workspaceCwd, messages, requireActiveSessionForLocalCommand, + sessionWriteBlocked, sessionActions, t, ]); const runVisibleBtw = useCallback( (rawQuestion: string) => { + if (sessionWriteBlocked) return; const question = rawQuestion.trim(); if (!question) { pushToast('error', t('btw.empty')); @@ -5489,6 +5551,7 @@ export function App({ const messageId = `local-btw-${nextBtwMessageIdRef.current++}`; const sessionId = connection.sessionId; + const workspaceCwd = connection.workspaceCwd; btwAbortControllerRef.current?.abort(); const abortController = new AbortController(); btwAbortControllerRef.current = abortController; @@ -5504,7 +5567,11 @@ export function App({ .btwSession(question, { signal: abortController.signal }) .then( (result) => { - if (currentSessionIdRef.current !== sessionId) return; + if ( + currentSessionIdRef.current !== sessionId || + connectionRef.current.workspaceCwd !== workspaceCwd + ) + return; if (btwAbortControllerRef.current !== abortController) return; btwAbortControllerRef.current = null; setBtwMessage({ @@ -5516,7 +5583,11 @@ export function App({ }); }, (error: unknown) => { - if (currentSessionIdRef.current !== sessionId) return; + if ( + currentSessionIdRef.current !== sessionId || + connectionRef.current.workspaceCwd !== workspaceCwd + ) + return; if (btwAbortControllerRef.current !== abortController) return; btwAbortControllerRef.current = null; setBtwMessage(null); @@ -5528,8 +5599,10 @@ export function App({ }, [ connection.sessionId, + connection.workspaceCwd, pushToast, requireActiveSessionForLocalCommand, + sessionWriteBlocked, sessionActions, t, ], @@ -5661,6 +5734,11 @@ export function App({ // re-creating on every render (and without an exhaustive-deps warning). const reloadProviders = providersState.reload; const [modelActionBusy, setModelActionBusy] = useState(false); + const modelActionTokenRef = useRef(0); + useLayoutEffect(() => { + modelActionTokenRef.current += 1; + setModelActionBusy(false); + }, [logicalSessionKey]); const { settings: workspaceSettings, setValue: setWorkspaceSetting, @@ -6017,6 +6095,8 @@ export function App({ const handleSettingsLanguageChange = useCallback( (nextLanguage: WebShellLanguage, scope: 'user' | 'workspace' = 'user') => { + if (sessionWriteBlocked) return; + const owner = { current: sessionOwnerGuard.capture() }; const previousLanguage = selectedLanguage; // Forward the settings tab's scope to the command so a Workspace-tab edit // persists to workspace settings instead of always writing user scope @@ -6026,8 +6106,9 @@ export function App({ const scopeFlag = scope === 'workspace' ? ' --project' : ' --global'; const command = `/language ui ${nextLanguage}${scopeFlag}`; handleLanguageChange(nextLanguage); - const refreshSettings = () => { - return Promise.all([ + const refreshSettings = async () => { + if (!owner.current.isCurrent()) return; + await Promise.all([ sessionActions.refreshCommands(), reloadWorkspaceSettings(), ]); @@ -6037,9 +6118,10 @@ export function App({ blockLocalCommandDuringTurn(); return; } - sendPrompt(command, undefined) + sendPrompt(command, undefined, { ownerRef: owner }) .then(refreshSettings) .catch((error: unknown) => { + if (!owner.current.isCurrent()) return; handleLanguageChange(previousLanguage); reportError(error, 'Failed to sync /language command'); }); @@ -6049,9 +6131,11 @@ export function App({ handleLanguageChange, reloadWorkspaceSettings, reportError, + sessionWriteBlocked, sendPrompt, selectedLanguage, sessionActions, + sessionOwnerGuard, ], ); @@ -6079,6 +6163,7 @@ export function App({ const handleSetMode = useCallback( (modeId: string) => { + if (sessionWriteBlocked) return; if (!isDaemonApprovalMode(modeId)) { reportError( new Error(`Unsupported approval mode: ${modeId}`), @@ -6090,9 +6175,11 @@ export function App({ setPendingMode(modeId); return; } + const owner = sessionOwnerGuard.capture(); sessionActions .setApprovalMode(modeId) .then((result) => { + if (!owner.isCurrent()) return; const effectiveMode = result.mode || modeId; setCurrentMode(effectiveMode); const approval = pendingApprovalRef.current; @@ -6121,10 +6208,19 @@ export function App({ } }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, t('local.approvalMode')); }); }, - [sessionActions, reportError, store, t, setPendingMode], + [ + sessionWriteBlocked, + reportError, + sessionActions, + sessionOwnerGuard, + setPendingMode, + store, + t, + ], ); useEffect(() => { @@ -6133,10 +6229,10 @@ export function App({ // Drop queued commands on a session switch so the drain never runs a // command against a different workspace's daemon (mirrors useQueuedPrompts). - const prevQueueSessionIdRef = useRef(connection.sessionId); + const prevQueueSessionIdRef = useRef(logicalSessionKey); useEffect(() => { - if (prevQueueSessionIdRef.current === connection.sessionId) return; - prevQueueSessionIdRef.current = connection.sessionId; + if (prevQueueSessionIdRef.current === logicalSessionKey) return; + prevQueueSessionIdRef.current = logicalSessionKey; const dropped = queuedShellCommandsRef.current.length; queuedShellCommandsRef.current = []; // Skip the bump when the transition is into the session that @@ -6149,7 +6245,7 @@ export function App({ if (dropped > 0) { pushToast('warning', t('queue.shellDropped', { count: dropped })); } - }, [connection.sessionId, pushToast, t]); + }, [connection.sessionId, logicalSessionKey, pushToast, t]); // Declared after the session-switch wipe effect above: React runs effects in // declaration order, so the queue is already cleared before this drain sees it. @@ -6172,6 +6268,7 @@ export function App({ const generation = ++drainGenerationRef.current; const drainSessionId = connectionRef.current.sessionId; const drainWorkspaceCwd = getComposerWorkspaceCwd(); + const drainOwner = sessionOwnerGuard.capture(); void (async () => { try { let batch = cmds; @@ -6180,6 +6277,7 @@ export function App({ const generationChanged = drainGenerationRef.current !== generation; if ( generationChanged || + !drainOwner.isCurrent() || connectionRef.current.sessionId !== drainSessionId || connectionRef.current.status !== 'connected' ) { @@ -6231,6 +6329,7 @@ export function App({ reportError, sessionActions, sessionCatalogController, + sessionOwnerGuard, streamingState, t, ]); @@ -6345,23 +6444,15 @@ export function App({ } }, [connection.error, onError]); - useEffect(() => { + useLayoutEffect(() => { setCurrentModel(connection.currentModel ?? ''); - }, [connection.currentModel, connection.sessionId]); + }, [connection.currentModel, logicalSessionKey]); - useEffect(() => { + useLayoutEffect(() => { setCurrentMode(connection.currentMode ?? 'default'); - }, [connection.currentMode, connection.sessionId]); + }, [connection.currentMode, logicalSessionKey]); useEffect(() => { - 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. @@ -6370,12 +6461,9 @@ export function App({ lastNotifiedWorkspaceCwdRef.current = undefined; return; } - // After a session is cleared the connection's workspaceCwd is a leftover - // from the previous session; reporting it would misroute the host back to - // the old workspace. activeWorkspaceCwd resolves the workspace picked for - // the next session (locked / selected / primary) and is what the composer - // chip reports, so the host and the chip stay in agreement. - const reportedWorkspaceCwd = activeWorkspaceCwd ?? connection.workspaceCwd; + const reportedWorkspaceCwd = connection.sessionId + ? connection.workspaceCwd + : activeWorkspaceCwd; const activeWorkspace = workspaces.find( (entry) => entry.cwd === reportedWorkspaceCwd, ); @@ -6410,7 +6498,6 @@ export function App({ ]); const lastRenameSessionRef = useRef(undefined); - const lastRenameWorkspaceCwdRef = useRef(undefined); const lastRenameNameRef = useRef(undefined); const lastReconciledRenameRef = useRef< | { @@ -6441,12 +6528,8 @@ export function App({ const sessionId = connection.sessionId; const displayName = connection.displayName; if (!sessionId || !displayName) return; - if ( - sessionId !== lastRenameSessionRef.current || - connection.workspaceCwd !== lastRenameWorkspaceCwdRef.current - ) { - lastRenameSessionRef.current = sessionId; - lastRenameWorkspaceCwdRef.current = connection.workspaceCwd; + if (logicalSessionKey !== lastRenameSessionRef.current) { + lastRenameSessionRef.current = logicalSessionKey; lastRenameNameRef.current = displayName; lastReconciledRenameRef.current = undefined; return; @@ -6478,6 +6561,7 @@ export function App({ connection.displayName, connection.sessionId, connection.workspaceCwd, + logicalSessionKey, sessionCatalogController, ]); @@ -6527,7 +6611,7 @@ export function App({ useEffect(() => { lastRecapBlockCountRef.current = 0; autoRecapVersionRef.current += 1; - }, [connection.sessionId]); + }, [logicalSessionKey]); useEffect(() => { const AWAY_THRESHOLD_MS = 3 * 60 * 1000; const MIN_NEW_BLOCKS = 4; @@ -6540,6 +6624,7 @@ export function App({ hiddenAtRef.current = null; if (hiddenAt === null) return; if (Date.now() - hiddenAt < AWAY_THRESHOLD_MS) return; + if (sessionWriteBlocked) return; if (streamingStateRef.current !== 'idle') return; if (!connection.sessionId) return; const currentCount = store.getSnapshot().blocks.length; @@ -6548,6 +6633,7 @@ export function App({ lastRecapBlockCountRef.current = currentCount; const sessionId = connection.sessionId; const version = autoRecapVersionRef.current; + const owner = sessionOwnerGuard.capture(); // Local-only commands also append user blocks. Treat any new visible user // activity as invalidating the recap rather than risk placing it too late. const userBlockId = getLatestUserBlockId(store.getSnapshot().blocks); @@ -6561,6 +6647,7 @@ export function App({ // catch those. Kept so it is not simplified away as redundant. if ( autoRecapVersionRef.current !== version || + !owner.isCurrent() || connectionRef.current.sessionId !== sessionId || result.sessionId !== sessionId || currentUserBlockId !== userBlockId || @@ -6596,7 +6683,14 @@ export function App({ document.addEventListener('visibilitychange', onVisibilityChange); return () => document.removeEventListener('visibilitychange', onVisibilityChange); - }, [connection.sessionId, sessionActions, store, t]); + }, [ + connection.sessionId, + sessionActions, + sessionOwnerGuard, + sessionWriteBlocked, + store, + t, + ]); const handleCycleMode = useCallback(() => { const idx = isDaemonApprovalMode(currentMode) @@ -6617,13 +6711,16 @@ export function App({ // "context detail" click) runs immediately, even mid-turn — only the // echo is skipped while streaming so the active turn is not split. if (!requireActiveSessionForLocalCommand()) return; + const owner = sessionOwnerGuard.capture(); echoLocalCommandIfIdle(commandText); sessionActions .getContextUsage({ detail }) .then((result) => { + if (!owner.isCurrent()) return; dispatchReadOnlyStatus(serializeContextUsageMessage(result)); }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, 'Failed to load context usage'); }); }, @@ -6632,6 +6729,7 @@ export function App({ dispatchReadOnlyStatus, requireActiveSessionForLocalCommand, sessionActions, + sessionOwnerGuard, reportError, ], ); @@ -6650,6 +6748,7 @@ export function App({ const branchCurrentSession = useCallback( (name?: string) => { + if (sessionWriteBlocked) return; if (!requireActiveSessionForLocalCommand()) return; sessionActions .branchSession(name || undefined) @@ -6670,6 +6769,7 @@ export function App({ [ reportError, requireActiveSessionForLocalCommand, + sessionWriteBlocked, sessionActions, store, t, @@ -6963,6 +7063,13 @@ export function App({ }, [], ); + const generateSuggestionContent = useCallback( + (prompt: string, options?: { signal?: AbortSignal }) => { + void logicalSessionKey; + return sessionActions.generateSessionContent(prompt, options); + }, + [logicalSessionKey, sessionActions], + ); const { suggestion: newSessionSuggestion, @@ -6981,7 +7088,7 @@ export function App({ isRunning: streamingState !== 'idle', dialogOpen: interactionBlocked || approvalOverlayActive, hasAttachments: hasComposerAttachments, - generateContent: sessionActions.generateSessionContent, + generateContent: generateSuggestionContent, }); const handleComposerTextChange = useCallback( @@ -7161,27 +7268,25 @@ export function App({ } }, [createNewSession, onSessionIdChange]); + const sessionOpenInvocationRef = useRef(0); const loadSidebarSession = useCallback( async (sessionId: string, workspaceCwd?: string) => { - composerSourceVersionRef.current += 1; + const invocation = ++sessionOpenInvocationRef.current; composerFocusRequestRef.current += 1; setSidebarSwitchingSessionId(sessionId); - setGitModeIntent({ mode: 'current' }); - setSessionWorktree(undefined); - setSessionBranch(undefined); - // Close the drawer before awaiting the load; the transcript clears - // immediately and shows its loading skeleton for the selected session. closeMobileDrawer(); // Loading another session should reveal its chat, not stay on the // Settings/Status panel (no-op when the panel is closed). closePanel(); try { - autoRecapVersionRef.current += 1; await sessionActions.loadSession(sessionId, { workspaceCwd }); + if (sessionOpenInvocationRef.current === invocation) { + composerSourceVersionRef.current += 1; + } } catch (error) { - setSidebarSwitchingSessionId((current) => - current === sessionId ? null : current, - ); + if (sessionOpenInvocationRef.current === invocation) { + setSidebarSwitchingSessionId(null); + } throw error; } }, @@ -7232,7 +7337,8 @@ export function App({ sidebarSwitchingSessionId !== null && connection.sessionId === sidebarSwitchingSessionId && !connection.loadingTranscript && - !connection.catchingUp + !connection.catchingUp && + !sessionWriteBlocked ) { setSidebarSwitchingSessionId(null); scheduleComposerFocus(sidebarSwitchingSessionId); @@ -7242,6 +7348,7 @@ export function App({ connection.loadingTranscript, connection.sessionId, scheduleComposerFocus, + sessionWriteBlocked, sidebarSwitchingSessionId, ]); @@ -7259,12 +7366,13 @@ export function App({ prompt: string; resolve: () => void; reject: (err: unknown) => void; - timer: ReturnType; + timer?: ReturnType; + owner?: { isCurrent(): boolean }; } | null>(null); const clearPendingBoundRun = useCallback((sessionId: string) => { const cur = pendingBoundRunRef.current; if (cur && cur.sessionId === sessionId) { - clearTimeout(cur.timer); + if (cur.timer !== undefined) clearTimeout(cur.timer); pendingBoundRunRef.current = null; } }, []); @@ -7319,12 +7427,27 @@ export function App({ if ( !pending || conn.sessionId !== pending.sessionId || - conn.loadingTranscript || - conn.catchingUp + conn.loadingTranscript ) { return; } - clearTimeout(pending.timer); + if (conn.catchingUp) { + if (pending.timer === undefined) { + pending.timer = setTimeout(() => { + clearPendingBoundRun(pending.sessionId); + pending.reject(new Error('Timed out waiting for session replay')); + }, BOUND_RUN_SWITCH_TIMEOUT_MS); + } + return; + } + if (pending.owner && !pending.owner.isCurrent()) { + clearPendingBoundRun(pending.sessionId); + pending.reject( + new DOMException('Bound run session was replaced', 'AbortError'), + ); + return; + } + if (pending.timer !== undefined) clearTimeout(pending.timer); pendingBoundRunRef.current = null; // Resolves at prompt admission (see enqueueManualRun); the switch-timeout was // cleared above, so a long turn can't trip it. Recording happens in the @@ -7333,7 +7456,7 @@ export function App({ () => pending.resolve(), (error: unknown) => pending.reject(error), ); - }, [enqueueManualRun]); + }, [clearPendingBoundRun, enqueueManualRun]); const runTaskManually = useCallback( (prompt: string, sessionId: string | null): Promise => { setMainView('chat'); @@ -7345,28 +7468,29 @@ export function App({ // reject the old promise so its caller doesn't record a dropped run. const prev = pendingBoundRunRef.current; if (prev) { - clearTimeout(prev.timer); + if (prev.timer !== undefined) clearTimeout(prev.timer); pendingBoundRunRef.current = null; prev.reject(new Error('superseded by another run')); } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - clearPendingBoundRun(sessionId); - reject(new Error('Timed out switching to the task session')); - }, BOUND_RUN_SWITCH_TIMEOUT_MS); - pendingBoundRunRef.current = { + const pending: NonNullable = { sessionId, prompt, resolve, reject, - timer, }; + pendingBoundRunRef.current = pending; loadSidebarSession(sessionId) // Fire immediately when the session was already active (no dep change // to trigger the effect); a no-op if the load is still settling, in // which case the effect picks it up. - .then(() => tryFireBoundRun()) + .then(() => { + if (pendingBoundRunRef.current !== pending) return; + pending.owner = sessionOwnerGuard.capture(); + tryFireBoundRun(); + }) .catch((error: unknown) => { + if (pendingBoundRunRef.current !== pending) return; clearPendingBoundRun(sessionId); reject(error); }); @@ -7376,6 +7500,7 @@ export function App({ enqueueManualRun, loadSidebarSession, clearPendingBoundRun, + sessionOwnerGuard, tryFireBoundRun, ], ); @@ -7390,16 +7515,24 @@ export function App({ const openTasksPanel = useCallback(() => { if (!requireActiveSessionForLocalCommand()) return; + const owner = sessionOwnerGuard.capture(); sessionActions .getTasks() .then((snapshot) => { + if (!owner.isCurrent()) return; setTasksDialogMessage({ snapshot }); }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; if (isSessionDisconnectedError(error)) return; reportError(error, 'Failed to load tasks'); }); - }, [reportError, requireActiveSessionForLocalCommand, sessionActions]); + }, [ + reportError, + requireActiveSessionForLocalCommand, + sessionActions, + sessionOwnerGuard, + ]); const openEnvironmentTasksPanel = useCallback(() => { if (!requireActiveSessionForLocalCommand()) return; setEnvironmentPanelOpen(true); @@ -7460,14 +7593,24 @@ export function App({ const handleBusyGoalClear = useCallback( (text: string) => { + if (sessionWriteBlocked) return false; if (!requireActiveSessionForLocalCommand()) return false; + const owner = sessionOwnerGuard.capture(); store.appendLocalUserMessage(text); sessionActions.clearGoal().catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, 'Failed to clear /goal'); }); return true; }, - [reportError, requireActiveSessionForLocalCommand, sessionActions, store], + [ + reportError, + requireActiveSessionForLocalCommand, + sessionWriteBlocked, + sessionActions, + sessionOwnerGuard, + store, + ], ); const loadRewindSnapshots = useCallback( @@ -7504,15 +7647,18 @@ export function App({ const goalArg = goalArgOf(text); const sendToDaemon = opts?.sendToDaemon ?? true; const sendGoalPrompt = () => { + const owner = { current: sessionOwnerGuard.capture() }; const deferComposerCommit = Boolean(onSubmitBeforeRef.current); const clearComposerOnPromptStart = !connectionRef.current.sessionId || deferComposerCommit; sendPrompt(text, images, { + ownerRef: owner, clearComposerOnPromptStart, commitComposerAccepted: clearComposerOnPromptStart ? opts?.commitComposerAccepted : undefined, }).catch((error: unknown) => { + if (!owner.current.isCurrent()) return; reportError(error, 'Failed to send /goal command'); }); return clearComposerOnPromptStart ? false : true; @@ -7547,6 +7693,7 @@ export function App({ openGoals, reportError, sendPrompt, + sessionOwnerGuard, store, connectionRef, ], @@ -7568,6 +7715,7 @@ export function App({ commitComposerAccepted?: ComposerSubmitCommit, metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => { + if (sessionWriteBlockedRef.current) return false; if ( unknownPromptAdmissionRef.current?.payloadAvailable && unknownPromptAdmissionRef.current.sessionId === @@ -7606,12 +7754,16 @@ export function App({ trackSendFailure?: boolean; }, ) => { + const admissionAttachment = { + current: sessionOwnerGuard.capture(), + }; const admissionOwner = { sourceVersion: composerSourceVersionRef.current, sessionId: connectionRef.current.sessionId, workspaceCwd: getComposerWorkspaceCwd(), }; const admissionOwnerIsCurrent = () => + admissionAttachment.current.isCurrent() && composerSourceVersionRef.current === admissionOwner.sourceVersion && (admissionOwner.sessionId === undefined || (connectionRef.current.sessionId === admissionOwner.sessionId && @@ -7625,6 +7777,7 @@ export function App({ let admissionStarted = false; let admissionSessionId: string | undefined; sendPrompt(promptText, promptImages, { + ownerRef: admissionAttachment, ...sendOptions, clearComposerOnPromptStart, commitComposerAccepted: clearComposerOnPromptStart @@ -7834,19 +7987,25 @@ export function App({ return true; } const nextLanguage = normalizeLanguage(languageArg); + const owner = { current: sessionOwnerGuard.capture() }; handleLanguageChange(nextLanguage); if (!promptBlocked) { const deferComposerCommit = Boolean(onSubmitBeforeRef.current); const clearComposerOnPromptStart = !connectionRef.current.sessionId || deferComposerCommit; sendPrompt(`/language ui ${nextLanguage}`, undefined, { + ownerRef: owner, clearComposerOnPromptStart, commitComposerAccepted: clearComposerOnPromptStart ? commitComposerAccepted : undefined, }) - .then(() => sessionActions.refreshCommands()) + .then(() => { + if (!owner.current.isCurrent()) return; + return sessionActions.refreshCommands(); + }) .catch((error: unknown) => { + if (!owner.current.isCurrent()) return; reportError(error, 'Failed to sync /language command'); }); return clearComposerOnPromptStart ? false : true; @@ -7897,9 +8056,11 @@ export function App({ pushToast('error', t('fork.empty')); return true; } + const owner = sessionOwnerGuard.capture(); sessionActions .forkSession(directive) .then((result) => { + if (!owner.isCurrent()) return; if (!result.launched) { pushToast('warning', t('fork.notStarted')); return; @@ -7911,6 +8072,7 @@ export function App({ ); }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; const reason = error instanceof Error ? error.message : String(error); reportError(error, t('fork.failed', { reason })); @@ -7976,12 +8138,15 @@ export function App({ setPendingModel(modelArg); return true; } + const owner = sessionOwnerGuard.capture(); sessionActions .setModel(modelArg) .then(() => { + if (!owner.isCurrent()) return; setPendingModel(modelArg); }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, t('model.switch')); }); } else { @@ -8004,10 +8169,15 @@ export function App({ } return true; } + const planPreparationToken = prompt + ? ++planPreparationTokenRef.current + : undefined; if (prompt) setIsPreparingPrompt(true); + const owner = sessionOwnerGuard.capture(); sessionActions .setApprovalMode('plan') .then(() => { + if (!owner.isCurrent()) return; setPendingMode('plan'); if (prompt) { return sendPrompt(prompt, images, { @@ -8019,10 +8189,16 @@ export function App({ } }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, t('mode.plan')); }) .finally(() => { - if (prompt) setIsPreparingPrompt(false); + if ( + prompt && + planPreparationTokenRef.current === planPreparationToken + ) { + setIsPreparingPrompt(false); + } }); return prompt ? false : true; } @@ -8313,6 +8489,7 @@ export function App({ if (!requireActiveSessionForLocalCommand()) return false; const renamedSessionId = connectionRef.current.sessionId; const renamedWorkspaceCwd = connectionRef.current.workspaceCwd; + const owner = sessionOwnerGuard.capture(); sessionActions .renameSession(displayName) .then(() => { @@ -8323,6 +8500,7 @@ export function App({ displayName, ); } + if (!owner.isCurrent()) return; store.dispatch([ { type: 'status', @@ -8336,6 +8514,7 @@ export function App({ renamedWorkspaceCwd, ); } + if (!owner.isCurrent()) return; reportError(error, 'Failed to rename session'); }); return true; @@ -8343,13 +8522,7 @@ export function App({ if (cmd === 'resume') { const sessionId = text.slice(match[0].length).trim(); if (sessionId) { - closeMobileDrawer(); - // Resuming a session means the user wants to see that chat, so - // close any open Settings/Status panel (no-op when already closed), - // consistent with createNewSession / loadSidebarSession. - closePanel(); - autoRecapVersionRef.current += 1; - sessionActions.loadSession(sessionId).catch((error: unknown) => { + loadSidebarSession(sessionId).catch((error: unknown) => { reportError(error, 'Failed to load session'); }); } else { @@ -8385,15 +8558,18 @@ export function App({ if (statsArg === 'model') statsView = 'model'; else if (statsArg === 'tools') statsView = 'tools'; if (!requireActiveSessionForLocalCommand()) return false; + const owner = sessionOwnerGuard.capture(); echoLocalCommandIfIdle(text); sessionActions .getStats() .then((result) => { + if (!owner.isCurrent()) return; dispatchReadOnlyStatus( serializeStatsMessage(result, statsView), ); }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, 'Failed to load stats'); }); return true; @@ -8610,6 +8786,7 @@ export function App({ [ sendPrompt, sessionActions, + sessionOwnerGuard, store, enqueuePrompt, echoOrDeferLocalCommand, @@ -8617,7 +8794,6 @@ export function App({ dispatchReadOnlyStatus, branchCurrentSession, closeMobileDrawer, - closePanel, openPanel, openScheduledTasks, openGoals, @@ -8638,6 +8814,7 @@ export function App({ sideTasksAvailable, openEnvironmentTasksPanel, hiddenCommands, + loadSidebarSession, pushToast, reportError, runVisibleRecap, @@ -8685,13 +8862,15 @@ export function App({ const handleConfirm = useCallback( (id: string, selectedOption: string, answers?: Record) => { + const owner = sessionOwnerGuard.capture(); sessionActions .submitPermission(id, selectedOption, answers) .catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, 'Failed to submit permission choice'); }); }, - [sessionActions, reportError], + [sessionActions, reportError, sessionOwnerGuard], ); const handleAskUserConfirm = useCallback( (id: string, selectedOption: string, answers?: Record) => @@ -8700,6 +8879,7 @@ export function App({ ); const handleCancel = useCallback(() => { + const owner = sessionOwnerGuard.capture(); const dropped = queuedShellCommandsRef.current.length; queuedShellCommandsRef.current = []; drainGenerationRef.current++; @@ -8709,9 +8889,10 @@ export function App({ pushToast('warning', t('queue.shellDropped', { count: dropped })); } sessionActions.cancel().catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, 'Failed to cancel request'); }); - }, [sessionActions, reportError, pushToast, t]); + }, [sessionActions, reportError, pushToast, sessionOwnerGuard, t]); const handleFocusTaskPill = useCallback((): boolean => { if (interactionBlocked) return false; @@ -8776,6 +8957,7 @@ export function App({ ); const handleRetry = useCallback(() => { + if (sessionWriteBlockedRef.current) return; if ( showRetryHintRef.current && connected && @@ -8787,13 +8969,15 @@ export function App({ ) { const retryErrorId = retryableTurnErrorIdRef.current; const retrySessionId = connectionRef.current.sessionId; + const retryWorkspaceCwd = getComposerWorkspaceCwd(); const retrySourceVersion = composerSourceVersionRef.current; const retryText = lastSubmittedPromptRef.current; const retryImages = lastSubmittedImagesRef.current; const retryInputAnnotations = lastSubmittedInputAnnotationsRef.current; const retryOwnerIsCurrent = () => composerSourceVersionRef.current === retrySourceVersion && - connectionRef.current.sessionId === retrySessionId; + connectionRef.current.sessionId === retrySessionId && + getComposerWorkspaceCwd() === retryWorkspaceCwd; retriedTurnErrorIdRef.current = retryErrorId; setShowRetryHint(false); setFailedPromptRetry({ @@ -8858,6 +9042,7 @@ export function App({ } }, [ connected, + getComposerWorkspaceCwd, pushToast, reportError, sendPrompt, @@ -9042,11 +9227,13 @@ export function App({ }; }, [resetEscapeState]); - const isDisabled = shouldDisableComposerInput({ - catchingUp: Boolean(connection.catchingUp), - pendingApproval: pendingApproval !== null, - isPreparingPrompt, - }); + const isDisabled = + sessionWriteBlocked || + shouldDisableComposerInput({ + catchingUp: Boolean(connection.catchingUp), + pendingApproval: pendingApproval !== null, + isPreparingPrompt, + }); const composerPlaceholderInputState = { catchingUp: Boolean(connection.catchingUp), isPreparingPrompt, @@ -9063,6 +9250,7 @@ export function App({ const handleModelSelect = useCallback( (modelId: string) => { + if (sessionWriteBlocked) return; if (!connectionRef.current.sessionId) { setPendingModel(modelId); return; @@ -9071,10 +9259,13 @@ export function App({ // selection is in flight — rapid Set current clicks would otherwise launch // concurrent setModel calls that can resolve out of order and leave a // model other than the user's last click active. + const owner = sessionOwnerGuard.capture(); + const modelActionToken = ++modelActionTokenRef.current; setModelActionBusy(true); sessionActions .setModel(modelId) .then((result) => { + if (!owner.isCurrent()) return; const summary = getModelSwitchSummary(result); setPendingModel(summary?.modelId ?? modelId); if (summary) { @@ -9087,15 +9278,29 @@ export function App({ } }) .catch((error: unknown) => { + if (!owner.isCurrent()) return; reportError(error, t('model.switch')); }) - .finally(() => setModelActionBusy(false)); + .finally(() => { + if (modelActionTokenRef.current === modelActionToken) { + setModelActionBusy(false); + } + }); }, - [sessionActions, store, reportError, t, setPendingModel], + [ + sessionWriteBlocked, + reportError, + sessionActions, + sessionOwnerGuard, + setPendingModel, + store, + t, + ], ); const handleDeleteModel = useCallback( (target: { authType: string; modelId: string; baseUrl?: string }) => { + const modelActionToken = ++modelActionTokenRef.current; setModelActionBusy(true); workspaceActions .deleteModel(target) @@ -9126,7 +9331,11 @@ export function App({ .catch((error: unknown) => { reportError(error, t('settings.models.deleteFailed')); }) - .finally(() => setModelActionBusy(false)); + .finally(() => { + if (modelActionTokenRef.current === modelActionToken) { + setModelActionBusy(false); + } + }); }, // Depend on the stable `reload` fn, not the whole providersState object, // which useProviders returns fresh each render (would defeat the memo). @@ -9215,8 +9424,12 @@ export function App({ // and ignore the user's User-vs-Workspace choice. const scopeFlag = modelSettingScope === 'user' ? ' --global' : ' --project'; - sendPrompt(`/model --fast ${modelId}${scopeFlag}`) + const owner = { current: sessionOwnerGuard.capture() }; + sendPrompt(`/model --fast ${modelId}${scopeFlag}`, undefined, { + ownerRef: owner, + }) .then(() => { + if (!owner.current.isCurrent()) return; // sendPrompt resolves only after the `/model --fast` turn *completes* // (actions.ts → waitForAcceptedPromptCompletion), so the change is // already applied here — this reload reads the new value, not a stale @@ -9233,6 +9446,7 @@ export function App({ }); }) .catch((error: unknown) => { + if (!owner.current.isCurrent()) return; reportError(error, 'Failed to switch fast model'); }); }, @@ -9244,6 +9458,7 @@ export function App({ reportError, reloadWorkspaceSettings, modelSettingScope, + sessionOwnerGuard, ], ); @@ -9715,14 +9930,9 @@ export function App({ { - closeMobileDrawer(); - closePanel(); - autoRecapVersionRef.current += 1; - sessionActions - .loadSession(sessionId) - .catch((error: unknown) => { - reportError(error, 'Failed to load session'); - }); + loadSidebarSession(sessionId).catch((error: unknown) => { + reportError(error, 'Failed to load session'); + }); }} onClose={() => setShowResumeDialog(false)} /> @@ -10629,17 +10839,24 @@ export function App({ // would land in an empty session with no explanation. // Letting this reject keeps the error in the form the // user is looking at. + const owner = { + current: sessionOwnerGuard.capture(), + }; try { await sendPrompt(`/goal ${condition}`, undefined, { clearComposerOnPromptStart: true, + ownerRef: owner, }); + if (!owner.current.isCurrent()) return false; } catch (error) { // `sendPrompt` creates the session lazily, so by now // one may exist even though the prompt never landed. // Remember it so the retry reuses it rather than // stranding it. - strandedGoalSessionRef.current = - connectionRef.current.sessionId; + if (owner.current.isCurrent()) { + strandedGoalSessionRef.current = + connectionRef.current.sessionId; + } throw error; } strandedGoalSessionRef.current = undefined; diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 1625c5bf737..c1de7b08f9c 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -110,6 +110,9 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ workspaceCwd: '/primary', }), useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }), + useDaemonSessionOwnerGuard: () => ({ + capture: () => ({ isCurrent: () => true }), + }), })); vi.mock('../session-catalog/session-catalog-hooks', () => ({ diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index ac197c5052a..509ebcb5c23 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -538,6 +538,7 @@ export function ChatPane({ } = useQueuedPrompts({ connected: connection.status === 'connected', sessionId: connection.sessionId, + workspaceCwd: connection.workspaceCwd, clientId: connection.clientId, canMutateMidTurn, canQueryMidTurn, diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx new file mode 100644 index 00000000000..d33392e209d --- /dev/null +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx @@ -0,0 +1,360 @@ +// @vitest-environment jsdom + +import { act, type ReactNode, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + connection: { + status: 'connected', + sessionId: 'session-a', + workspaceCwd: '/work/a', + } as Record, + workspace: { + status: 'connected', + capabilities: { + workspaceCwd: '/work/a', + features: ['client_identity'], + workspaces: [ + { id: 'a', cwd: '/work/a', primary: true, trusted: true }, + { id: 'b', cwd: '/work/b', primary: false, trusted: true }, + ], + }, + refreshCapabilities: vi.fn(async () => undefined), + } as Record, + addWorkspace: vi.fn(), + providerMounts: 0, + providerUnmounts: 0, + providerProps: [] as Array>, + appProps: [] as Array>, +})); + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + DaemonSessionProvider: ({ + children, + ...props + }: Record & { children: ReactNode }) => { + mocks.providerProps.push(props); + useEffect(() => { + mocks.providerMounts += 1; + return () => { + mocks.providerUnmounts += 1; + }; + }, []); + return children; + }, + useWorkspace: () => mocks.workspace, + useConnection: () => mocks.connection, + useWorkspaceActions: () => ({ addWorkspace: mocks.addWorkspace }), +})); + +vi.mock('../App', () => ({ + App: (props: Record) => { + mocks.appProps.push(props); + return ( + {String(props['initialSelectedWorkspaceCwd'] ?? '')} + ); + }, +})); + +import { WorkspaceSessionProvider } from './WorkspaceSessionProvider'; + +describe('WorkspaceSessionProvider transactional targets', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.connection = { + status: 'connected', + sessionId: 'session-a', + workspaceCwd: '/work/a', + }; + mocks.workspace = { + status: 'connected', + capabilities: { + workspaceCwd: '/work/a', + features: ['client_identity'], + workspaces: [ + { id: 'a', cwd: '/work/a', primary: true, trusted: true }, + { id: 'b', cwd: '/work/b', primary: false, trusted: true }, + ], + }, + refreshCapabilities: vi.fn(async () => undefined), + }; + mocks.addWorkspace.mockReset(); + mocks.providerMounts = 0; + mocks.providerUnmounts = 0; + mocks.providerProps = []; + mocks.appProps = []; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + async function renderTarget( + sessionId: string, + workspaceCwd: string, + onSessionIdChange = vi.fn(), + ) { + await act(async () => { + root.render( + , + ); + }); + return onSessionIdChange; + } + + it('keeps the modern provider mounted until the desired target commits', async () => { + const onSessionIdChange = await renderTarget('session-a', '/work/a'); + expect(mocks.providerMounts).toBe(1); + expect(container.textContent).toBe('/work/a'); + + await renderTarget('session-b', '/work/b', onSessionIdChange); + expect(mocks.providerMounts).toBe(1); + expect(mocks.providerUnmounts).toBe(0); + expect(mocks.providerProps.at(-1)).toMatchObject({ + sessionId: 'session-b', + workspaceCwd: '/work/b', + }); + expect(mocks.appProps.at(-1)).toMatchObject({ + desiredSessionTargetPending: true, + initialSelectedWorkspaceCwd: '/work/a', + }); + + await act(async () => { + const commit = mocks.providerProps.at(-1)?.[ + 'onSessionTransitionCommit' + ] as (target: { sessionId: string; workspaceCwd: string }) => void; + commit({ sessionId: 'session-b', workspaceCwd: '/work/b' }); + }); + expect(container.textContent).toBe('/work/b'); + expect(mocks.providerProps.at(-1)).toMatchObject({ + sessionId: 'session-b', + workspaceCwd: '/work/b', + }); + expect(mocks.appProps.at(-1)).toMatchObject({ + desiredSessionTargetPending: false, + }); + const appReport = mocks.appProps.at(-1)?.['onSessionIdChange'] as ( + sessionId: string, + workspaceId: string, + workspaceCwd: string, + ) => void; + appReport('session-b', 'b', '/work/b'); + expect(onSessionIdChange).toHaveBeenCalledTimes(1); + expect(onSessionIdChange).toHaveBeenCalledWith('session-b', 'b', '/work/b'); + }); + + it('does not feed stale host props back after an action-driven commit', async () => { + const onSessionIdChange = await renderTarget('session-a', '/work/a'); + + await act(async () => { + const commit = mocks.providerProps.at(-1)?.[ + 'onSessionTransitionCommit' + ] as (target: { sessionId: string; workspaceCwd: string }) => void; + commit({ sessionId: 'session-b', workspaceCwd: '/work/b' }); + }); + + expect(mocks.providerProps.at(-1)).toMatchObject({ + sessionId: 'session-b', + workspaceCwd: '/work/b', + }); + expect(onSessionIdChange).not.toHaveBeenCalled(); + const appReport = mocks.appProps.at(-1)?.['onSessionIdChange'] as ( + sessionId: string, + workspaceId: string, + workspaceCwd: string, + ) => void; + appReport('session-b', 'b', '/work/b'); + expect(onSessionIdChange).toHaveBeenCalledWith('session-b', 'b', '/work/b'); + }); + + it('keeps the committed app visible while a workspace target is unresolved', async () => { + const onSessionIdChange = await renderTarget('session-a', '/work/a'); + mocks.workspace = { + ...mocks.workspace, + capabilities: undefined, + }; + + await renderTarget('session-b', '/work/missing', onSessionIdChange); + expect(mocks.providerMounts).toBe(1); + expect(container.textContent).toBe('/work/a'); + expect(mocks.providerProps.at(-1)).toMatchObject({ + sessionId: 'session-a', + workspaceCwd: '/work/a', + }); + expect(mocks.appProps.at(-1)).toMatchObject({ + desiredSessionTargetPending: true, + }); + }); + + it('unblocks the committed session after workspace resolution fails', async () => { + const onSessionIdChange = await renderTarget('session-a', '/work/a'); + onSessionIdChange.mockClear(); + mocks.workspace = { + ...mocks.workspace, + status: 'error', + capabilities: { + workspaceCwd: '/work/a', + features: ['client_identity'], + workspaces: [{ id: 'a', cwd: '/work/a', primary: true, trusted: true }], + }, + }; + + await renderTarget('session-b', '/work/missing', onSessionIdChange); + + expect(mocks.providerMounts).toBe(1); + expect(container.textContent).toBe('/work/a'); + expect(mocks.appProps.at(-1)).toMatchObject({ + desiredSessionTargetPending: false, + }); + expect(onSessionIdChange).toHaveBeenCalledTimes(1); + expect(onSessionIdChange).toHaveBeenCalledWith('session-a', 'a', '/work/a'); + + await renderTarget('session-b', '/work/missing', onSessionIdChange); + expect(onSessionIdChange).toHaveBeenCalledTimes(1); + }); + + it('does not preserve a target that never connected', async () => { + mocks.connection = { status: 'error' }; + const onSessionIdChange = await renderTarget('session-a', '/work/a'); + mocks.workspace = { + ...mocks.workspace, + capabilities: { + workspaceCwd: '/work/a', + features: ['client_identity'], + workspaces: [{ id: 'a', cwd: '/work/a', primary: true, trusted: true }], + }, + }; + + await renderTarget('session-b', '/work/missing', onSessionIdChange); + + expect(mocks.providerUnmounts).toBe(1); + expect(container.textContent).not.toContain('/work/a'); + }); + + it('rolls a still-current controlled target back after restore failure', async () => { + const onSessionIdChange = await renderTarget('session-a', '/work/a'); + onSessionIdChange.mockClear(); + await renderTarget('session-b', '/work/b', onSessionIdChange); + mocks.connection = { + status: 'connected', + sessionId: 'session-a', + workspaceCwd: '/work/a', + sessionTransition: { + phase: 'failed', + operation: 'load', + origin: 'controlled', + targetSessionId: 'session-b', + targetWorkspaceCwd: '/work/b', + }, + }; + await renderTarget('session-b', '/work/b', onSessionIdChange); + expect(onSessionIdChange).toHaveBeenCalledTimes(1); + expect(onSessionIdChange).toHaveBeenCalledWith('session-a', 'a', '/work/a'); + expect(mocks.appProps.at(-1)).toMatchObject({ + desiredSessionTargetPending: false, + }); + }); + + it('rolls back a primary-workspace target when workspace props are omitted', async () => { + const onSessionIdChange = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + onSessionIdChange.mockClear(); + + await act(async () => { + root.render( + , + ); + }); + mocks.connection = { + status: 'connected', + sessionId: 'session-a', + workspaceCwd: '/work/a', + sessionTransition: { + phase: 'failed', + operation: 'load', + origin: 'controlled', + targetSessionId: 'session-b', + targetWorkspaceCwd: '/work/a', + }, + }; + await act(async () => { + root.render( + , + ); + }); + + expect(onSessionIdChange).toHaveBeenCalledWith('session-a', 'a', '/work/a'); + }); + + it('preserves keyed remounts for legacy daemons', async () => { + mocks.workspace = { + ...mocks.workspace, + capabilities: { + workspaceCwd: '/work/a', + features: [], + workspaces: [ + { id: 'a', cwd: '/work/a', primary: true, trusted: true }, + { id: 'b', cwd: '/work/b', primary: false, trusted: true }, + ], + }, + }; + const onSessionIdChange = await renderTarget('session-a', '/work/a'); + await renderTarget('session-b', '/work/b', onSessionIdChange); + expect(mocks.providerMounts).toBe(2); + expect(mocks.providerUnmounts).toBe(1); + expect(mocks.appProps.at(-1)).toMatchObject({ + initialSelectedWorkspaceCwd: '/work/b', + }); + }); + + it('does not remount when an unknown daemon resolves as modern', async () => { + mocks.workspace = { ...mocks.workspace, capabilities: undefined }; + await act(async () => { + root.render( + , + ); + }); + expect(mocks.providerMounts).toBe(1); + + mocks.workspace = { + ...mocks.workspace, + capabilities: { + workspaceCwd: '/work/a', + features: ['client_identity'], + workspaces: [{ id: 'a', cwd: '/work/a', primary: true, trusted: true }], + }, + }; + await act(async () => { + root.render( + , + ); + }); + + expect(mocks.providerMounts).toBe(1); + expect(mocks.providerUnmounts).toBe(0); + }); +}); diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx index c080852f3c1..696f3913383 100644 --- a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx @@ -1,10 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { WifiOffIcon } from 'lucide-react'; import { DaemonSessionProvider, + useConnection, useWorkspace, useWorkspaceActions, } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonConnectionState } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonWorkspaceCapability } from '@qwen-code/sdk/daemon'; import { App, type WebShellProps } from '../App'; import { @@ -14,6 +16,17 @@ import { import { getTranslator, normalizeLanguage } from '../i18n'; import { Spinner } from './ui/spinner'; import { WorkspaceUnavailableState } from './WorkspaceUnavailableState'; +const CLIENT_IDENTITY_FEATURE = 'client_identity'; +type CommittedSessionTarget = { sessionId: string; workspaceCwd?: string }; +function SessionStateObserver({ + onChange, +}: { + onChange: (connection: DaemonConnectionState) => void; +}) { + const connection = useConnection(); + useEffect(() => onChange(connection), [connection, onChange]); + return null; +} interface WorkspaceSessionProviderProps { sessionId?: string; @@ -91,10 +104,168 @@ export function WorkspaceSessionProvider({ : workspace.capabilities?.workspaces?.find( (entry) => entry.id === effectiveWorkspaceId, ); + const desiredWorkspace = + targetWorkspace ?? + (!effectiveWorkspaceCwd && !effectiveWorkspaceId + ? workspace.capabilities?.workspaces?.find( + (entry) => + entry.primary || entry.cwd === workspace.capabilities?.workspaceCwd, + ) + : undefined); const t = useMemo( () => getTranslator(normalizeLanguage(webShellProps.language)), [webShellProps.language], ); + const onSessionIdChange = webShellProps.onSessionIdChange; + const transactionalRef = useRef(undefined); + if (workspace.capabilities) { + transactionalRef.current = workspace.capabilities.features.includes( + CLIENT_IDENTITY_FEATURE, + ); + } + const transactional = transactionalRef.current === true; + const desiredKey = `${effectiveSessionId ?? ''}\0${effectiveWorkspaceCwd ?? effectiveWorkspaceId ?? ''}`; + const [, setCommittedTarget] = useState(); + const committedTargetRef = useRef( + undefined, + ); + const pendingHostCommitKeyRef = useRef(undefined); + if ( + pendingHostCommitKeyRef.current !== undefined && + pendingHostCommitKeyRef.current !== desiredKey + ) { + pendingHostCommitKeyRef.current = undefined; + } + const installCommittedTarget = useCallback( + (target: CommittedSessionTarget) => { + committedTargetRef.current = target; + setCommittedTarget(target); + }, + [], + ); + const commitTarget = useCallback( + (target: CommittedSessionTarget) => { + pendingHostCommitKeyRef.current = + target.sessionId !== effectiveSessionId || + target.workspaceCwd !== desiredWorkspace?.cwd + ? desiredKey + : undefined; + installCommittedTarget(target); + }, + [ + desiredKey, + desiredWorkspace?.cwd, + effectiveSessionId, + installCommittedTarget, + ], + ); + const canKeepCommitted = + transactional && committedTargetRef.current !== undefined; + const desiredTargetResolved = + (!effectiveWorkspaceCwd && !effectiveWorkspaceId) || + targetWorkspace !== undefined; + const failureLatchRef = useRef(undefined); + const desiredTargetFailed = + workspace.status === 'error' || + (!desiredTargetResolved && + ((lockWorkspaceCwd !== undefined && + registrationErrorCwd === lockWorkspaceCwd) || + (workspace.capabilities !== undefined && !lockWorkspaceCwd))); + const desiredTargetReady = desiredTargetResolved && !desiredTargetFailed; + const controlledTargetUncommitted = + effectiveSessionId !== undefined && + pendingHostCommitKeyRef.current !== desiredKey && + (effectiveSessionId !== committedTargetRef.current?.sessionId || + desiredWorkspace?.cwd !== committedTargetRef.current?.workspaceCwd); + const desiredTargetPending = + canKeepCommitted && + failureLatchRef.current !== desiredKey && + !desiredTargetFailed && + (!desiredTargetReady || controlledTargetUncommitted); + const reportCommittedTarget = useCallback(() => { + const committed = committedTargetRef.current; + if (!committed) return; + const workspaceId = workspace.capabilities?.workspaces?.find( + (entry) => entry.cwd === committed.workspaceCwd, + )?.id; + onSessionIdChange?.( + committed.sessionId, + workspaceId, + committed.workspaceCwd, + ); + }, [onSessionIdChange, workspace.capabilities?.workspaces]); + const observeSessionState = useCallback( + (connection: DaemonConnectionState) => { + if ( + transactional && + connection.status === 'connected' && + connection.sessionId + ) { + installCommittedTarget({ + sessionId: connection.sessionId, + workspaceCwd: connection.workspaceCwd, + }); + } + const transition = connection.sessionTransition; + if ( + transition?.phase === 'failed' && + transition.targetSessionId === effectiveSessionId && + transition.targetWorkspaceCwd === desiredWorkspace?.cwd && + failureLatchRef.current !== desiredKey + ) { + failureLatchRef.current = desiredKey; + reportCommittedTarget(); + } + }, + [ + desiredKey, + effectiveSessionId, + installCommittedTarget, + reportCommittedTarget, + desiredWorkspace?.cwd, + transactional, + ], + ); + + useEffect(() => { + if (!canKeepCommitted || desiredTargetReady) { + if (failureLatchRef.current !== desiredKey) { + failureLatchRef.current = undefined; + } + return; + } + if (!desiredTargetFailed || failureLatchRef.current === desiredKey) return; + failureLatchRef.current = desiredKey; + reportCommittedTarget(); + }, [ + canKeepCommitted, + desiredKey, + desiredTargetFailed, + desiredTargetReady, + reportCommittedTarget, + ]); + const keepCommittedTarget = + canKeepCommitted && + (!desiredTargetReady || pendingHostCommitKeyRef.current === desiredKey); + const providerSessionId = keepCommittedTarget + ? committedTargetRef.current!.sessionId + : effectiveSessionId; + const providerWorkspaceCwd = keepCommittedTarget + ? committedTargetRef.current!.workspaceCwd + : desiredWorkspace?.cwd; + const visibleWorkspaceCwd = canKeepCommitted + ? committedTargetRef.current!.workspaceCwd + : desiredWorkspace?.cwd; + const visibleWorkspace = + (desiredWorkspace?.cwd === visibleWorkspaceCwd + ? desiredWorkspace + : undefined) ?? + workspace.capabilities?.workspaces?.find( + (entry) => entry.cwd === visibleWorkspaceCwd, + ) ?? + (registeredLockedWorkspace?.cwd === visibleWorkspaceCwd + ? registeredLockedWorkspace + : undefined); useEffect(() => { if (!lockWorkspaceCwd || !workspace.capabilities || pathWorkspace) return; @@ -151,7 +322,8 @@ export function WorkspaceSessionProvider({ if ( (effectiveWorkspaceCwd || effectiveWorkspaceId) && - workspace.status === 'error' + workspace.status === 'error' && + !canKeepCommitted ) { return ( ); } - if (lockWorkspaceCwd && registrationErrorCwd === lockWorkspaceCwd) { + if ( + lockWorkspaceCwd && + registrationErrorCwd === lockWorkspaceCwd && + !canKeepCommitted + ) { return ( ); } - if (lockWorkspaceCwd && !targetWorkspace) { + if (lockWorkspaceCwd && !targetWorkspace && !canKeepCommitted) { return (
); } - if ((effectiveWorkspaceCwd || effectiveWorkspaceId) && !targetWorkspace) { + if ( + (effectiveWorkspaceCwd || effectiveWorkspaceId) && + !targetWorkspace && + !canKeepCommitted + ) { return ( + diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx index e010a87f868..e7bd5d7d68e 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx +++ b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx @@ -22,6 +22,8 @@ interface Deferred { } const sdkMock = vi.hoisted(() => ({ + ownerVersion: 0, + ownerGuard: { capture: vi.fn() }, actions: { getTasks: vi.fn(), }, @@ -29,6 +31,7 @@ const sdkMock = vi.hoisted(() => ({ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useActions: () => sdkMock.actions, + useDaemonSessionOwnerGuard: () => sdkMock.ownerGuard, })); let root: Root | null = null; @@ -109,6 +112,11 @@ beforeEach(() => { refreshTrigger = 0; latestTasks = []; sdkMock.actions.getTasks.mockReset(); + sdkMock.ownerVersion = 0; + sdkMock.ownerGuard.capture.mockImplementation(() => { + const version = sdkMock.ownerVersion; + return { isCurrent: () => sdkMock.ownerVersion === version }; + }); }); afterEach(async () => { @@ -205,6 +213,7 @@ describe('useBackgroundTasks', () => { }); sessionId = 'session-b'; + sdkMock.ownerVersion += 1; await rerenderHarness(); expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); expect(sdkMock.actions.getTasks).toHaveBeenLastCalledWith({ @@ -234,4 +243,45 @@ describe('useBackgroundTasks', () => { silent: true, }); }); + + it('ignores an old attachment response when the session id is unchanged', async () => { + const request = deferred(); + sdkMock.actions.getTasks.mockReturnValueOnce(request.promise); + await renderHarness(); + + sdkMock.ownerVersion += 1; + await act(async () => { + request.resolve( + snapshot('session-a', [monitor('stale-monitor', 'running')]), + ); + await request.promise; + }); + + expect(latestTasks).toEqual([]); + }); + + it('starts polling a replacement attachment while the old request hangs', async () => { + const oldRequest = deferred(); + const runningMonitor = monitor('replacement-monitor', 'running'); + sdkMock.actions.getTasks + .mockReturnValueOnce(oldRequest.promise) + .mockResolvedValueOnce(snapshot('session-a', [runningMonitor])); + + await renderHarness(); + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(1); + + sdkMock.ownerVersion += 1; + await rerenderHarness(); + + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + expect(latestTasks).toEqual([runningMonitor]); + + await act(async () => { + oldRequest.resolve( + snapshot('session-a', [monitor('stale-monitor', 'completed')]), + ); + await oldRequest.promise; + }); + expect(latestTasks).toEqual([runningMonitor]); + }); }); diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.ts b/packages/web-shell/client/hooks/useBackgroundTasks.ts index 253495fcaed..33485f39f62 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.ts +++ b/packages/web-shell/client/hooks/useBackgroundTasks.ts @@ -1,6 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; -import { useActions } from '@qwen-code/webui/daemon-react-sdk'; +import { + useActions, + useDaemonSessionOwnerGuard, +} from '@qwen-code/webui/daemon-react-sdk'; import { TASKS_STATUS_ACTIVE_EVENT } from '../components/messages/TasksStatusMessage'; import { isSessionDisconnectedError } from '../utils/sessionErrors'; @@ -20,32 +23,30 @@ export function useBackgroundTasks( refreshTrigger = 0, ): DaemonSessionTaskStatus[] { const actions = useActions(); + const ownerGuard = useDaemonSessionOwnerGuard(); + const ownerRef = useRef(ownerGuard.capture()); + if (!ownerRef.current?.isCurrent()) ownerRef.current = ownerGuard.capture(); + const owner = ownerRef.current; const [tasks, setTasks] = useState([]); + const tasksOwnerRef = useRef(owner); const [pollingActive, setPollingActive] = useState(false); const [tasksPanelActive, setTasksPanelActive] = useState(false); const emptyPollsRef = useRef(0); - const tasksRefreshInFlightRef = useRef<{ - sessionId: string; - request: object; - } | null>(null); + const tasksRefreshInFlightRef = useRef(null); useEffect(() => { + tasksOwnerRef.current = owner; setTasks([]); setPollingActive(false); emptyPollsRef.current = 0; - }, [connected, sessionId]); + }, [connected, owner, sessionId]); useEffect(() => { - if (!connected || !sessionId || !taskActivityKey) return; + if (!connected || !sessionId || (!taskActivityKey && refreshTrigger === 0)) + return; emptyPollsRef.current = 0; setPollingActive(true); - }, [connected, sessionId, taskActivityKey]); - - useEffect(() => { - if (!connected || !sessionId || refreshTrigger === 0) return; - emptyPollsRef.current = 0; - setPollingActive(true); - }, [connected, refreshTrigger, sessionId]); + }, [connected, owner, refreshTrigger, sessionId, taskActivityKey]); useEffect(() => { if (tasksPanelActive) return; @@ -53,13 +54,17 @@ export function useBackgroundTasks( let disposed = false; const refresh = () => { - if (tasksRefreshInFlightRef.current?.sessionId === sessionId) return; - const request = {}; - tasksRefreshInFlightRef.current = { sessionId, request }; + if (tasksRefreshInFlightRef.current === owner) return; + tasksRefreshInFlightRef.current = owner; actions .getTasks({ silent: true }) .then((snapshot) => { - if (disposed || snapshot.sessionId !== sessionId) return; + if ( + disposed || + !owner.isCurrent() || + snapshot.sessionId !== sessionId + ) + return; setTasks(snapshot.tasks); if (snapshot.tasks.length === 0) { emptyPollsRef.current += 1; @@ -74,7 +79,7 @@ export function useBackgroundTasks( } }) .catch((error: unknown) => { - if (disposed) return; + if (disposed || !owner.isCurrent()) return; if (isSessionDisconnectedError(error)) { setPollingActive(false); return; @@ -82,7 +87,7 @@ export function useBackgroundTasks( console.warn('[web-shell] failed to refresh tasks:', error); }) .finally(() => { - if (tasksRefreshInFlightRef.current?.request === request) { + if (tasksRefreshInFlightRef.current === owner) { tasksRefreshInFlightRef.current = null; } }); @@ -94,7 +99,7 @@ export function useBackgroundTasks( disposed = true; clearInterval(id); }; - }, [actions, connected, pollingActive, sessionId, tasksPanelActive]); + }, [actions, connected, owner, pollingActive, sessionId, tasksPanelActive]); const tasksRef = useRef(tasks); tasksRef.current = tasks; @@ -113,5 +118,5 @@ export function useBackgroundTasks( window.removeEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); }, []); - return tasks; + return tasksOwnerRef.current === owner ? tasks : []; } diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx index 6b47901ad52..ac27515d7c1 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx @@ -37,6 +37,7 @@ const sdk = vi.hoisted(() => ({ originatorClientId?: string; }>, consume: vi.fn(), + ownerVersion: 0, })); vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ @@ -49,6 +50,12 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ batches: sdk.batches, consume: sdk.consume, }), + useDaemonSessionOwnerGuard: () => ({ + capture: () => { + const ownerVersion = sdk.ownerVersion; + return { isCurrent: () => sdk.ownerVersion === ownerVersion }; + }, + }), })); ( @@ -75,6 +82,7 @@ function mount( sessionActions: DaemonSessionActions, canMutateMidTurn = true, connected = false, + writeBlocked = false, ) { const editor = { getText: vi.fn(() => ''), @@ -92,13 +100,17 @@ function mount( function Harness({ state, activeSessionId, + blocked, }: { state: typeof streamingState; activeSessionId: string; + blocked: boolean; }) { latest = useQueuedPrompts({ connected, + writeBlocked: blocked, sessionId: activeSessionId, + workspaceCwd: '/workspace', clientId: 'client-1', canMutateMidTurn, // This suite pins the legacy local-fallback lifecycle. @@ -114,13 +126,24 @@ function mount( } let activeSessionId = 'session-1'; + let blocked = writeBlocked; const render = ( state: typeof streamingState, nextSessionId = activeSessionId, + replaceOwner = false, + nextWriteBlocked = blocked, ) => { + if (replaceOwner) sdk.ownerVersion += 1; activeSessionId = nextSessionId; + blocked = nextWriteBlocked; act(() => - root.render(), + root.render( + , + ), ); }; render(streamingState); @@ -151,6 +174,7 @@ beforeEach(() => { sdk.batches = []; sdk.pendingEvents = []; sdk.consume.mockReset(); + sdk.ownerVersion = 0; }); afterEach(() => { @@ -159,6 +183,27 @@ afterEach(() => { }); describe('useQueuedPrompts default mid-turn insertion', () => { + it('restores an unaccepted mid-turn prompt when its owner is replaced', () => { + const { actions } = createActions(); + vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue( + new Promise(() => undefined), + ); + const { editor, render } = mount('responding', actions); + + act(() => latest.enqueuePrompt('belongs to the source attachment')); + expect(latest.queuedPrompts).toHaveLength(1); + + render('responding', 'session-1', true); + + expect(latest.queuedPrompts).toEqual([]); + expect(editor.setText).toHaveBeenCalledWith( + 'belongs to the source attachment', + ); + const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1] + ?.signal; + expect(signal?.aborted).toBe(true); + }); + it('queues and submits an image-only prompt without using mid-turn text insertion', () => { const { actions } = createActions(); mount('responding', actions); @@ -515,6 +560,24 @@ describe('useQueuedPrompts default mid-turn insertion', () => { expect(store.appendLocalUserMessage).not.toHaveBeenCalled(); }); + it('fences an old submit before the replacement owner rerenders', async () => { + const { actions, pendingSubmit } = createActions(); + const { render, store } = mount('responding', actions); + + act(() => + latest.enqueuePrompt('', [{ data: 'b2xk', media_type: 'image/png' }]), + ); + sdk.ownerVersion += 1; + await act(async () => { + pendingSubmit.resolve({ promptId: 'old-server-prompt' }); + await Promise.resolve(); + }); + + expect(store.appendLocalUserMessage).not.toHaveBeenCalled(); + render('responding', 'session-1'); + expect(latest.queuedPrompts).toEqual([]); + }); + it('ignores an old refresh after an S1 to S2 to S1 owner change', async () => { const { actions } = createActions(); const oldRefresh = deferred<{ @@ -804,6 +867,26 @@ describe('useQueuedPrompts default mid-turn insertion', () => { ]); }); + it('freezes mid-turn fallback while a session switch is preparing', async () => { + const { actions } = createActions(); + const admission = deferred<{ accepted: boolean }>(); + vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise); + const { render } = mount('responding', actions); + + act(() => latest.enqueuePrompt('留在当前会话')); + render('responding', 'session-1', false, true); + await act(async () => admission.resolve({ accepted: false })); + render('idle', 'session-1', false, true); + + expect(actions.submitPrompt).not.toHaveBeenCalled(); + expect(latest.queuedPrompts).toMatchObject([ + { text: '留在当前会话', midTurnState: 'submitting' }, + ]); + + render('idle', 'session-1', false, false); + expect(actions.submitPrompt).toHaveBeenCalledOnce(); + }); + it('falls back once when the running turn ends before injection', async () => { const { actions } = createActions(); vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({ @@ -907,6 +990,87 @@ describe('useQueuedPrompts default mid-turn insertion', () => { expect(editor.focus).toHaveBeenCalled(); }); + it('restores an edited prompt after a same-id attachment replacement', async () => { + const { actions } = createActions(); + const removal = deferred<{ removed: boolean }>(); + vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({ + accepted: true, + messageId: 'mid-edit', + }); + vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise); + const { editor, render } = mount('responding', actions); + + act(() => latest.enqueuePrompt('修改后保留')); + await act(async () => {}); + let editPromise!: Promise; + act(() => { + editPromise = latest.editQueuedPrompt(1); + }); + render('responding', 'session-1', true); + await act(async () => { + removal.resolve({ removed: true }); + await editPromise; + }); + + expect(editor.setText).toHaveBeenCalledWith('修改后保留'); + expect(editor.setText).toHaveBeenCalledOnce(); + expect(editor.focus).toHaveBeenCalled(); + }); + + it('restores an edited prompt when switching to a different session', async () => { + const { actions } = createActions(); + const removal = deferred<{ removed: boolean }>(); + vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({ + accepted: true, + messageId: 'mid-cross-session-edit', + }); + vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise); + const { editor, render } = mount('responding', actions); + + act(() => latest.enqueuePrompt('切换后保留')); + await act(async () => {}); + let editPromise!: Promise; + act(() => { + editPromise = latest.editQueuedPrompt(1); + }); + render('responding', 'session-2', true); + await act(async () => { + removal.resolve({ removed: true }); + await editPromise; + }); + + expect(editor.setText).toHaveBeenCalledWith('切换后保留'); + expect(editor.setText).toHaveBeenCalledOnce(); + expect(latest.queuedPrompts).toEqual([]); + }); + + it('does not restore an edited prompt when cross-session removal loses', async () => { + const { actions } = createActions(); + const removal = deferred<{ removed: boolean }>(); + vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({ + accepted: true, + messageId: 'mid-cross-session-edit-lost', + }); + vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise); + const { editor, render } = mount('responding', actions); + + act(() => latest.enqueuePrompt('仍在服务端')); + await act(async () => {}); + let editPromise!: Promise; + act(() => { + editPromise = latest.editQueuedPrompt(1); + }); + render('responding', 'session-2', true); + expect(editor.setText).not.toHaveBeenCalled(); + await act(async () => { + removal.resolve({ removed: false }); + await editPromise; + }); + + expect(editor.setText).not.toHaveBeenCalled(); + expect(latest.queuedPrompts).toEqual([]); + }); + it('keeps the row when removal loses the race with drain or idle', async () => { const { actions } = createActions(); vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({ diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx index 6c0102e780c..16b18c0b620 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx @@ -35,6 +35,7 @@ const sdkMock = vi.hoisted(() => { }>, consumeInjected: vi.fn(), pendingEvents: [] as Array>, + ownerVersion: 0, pendingEventListeners, publishPendingEvents: (events: Array>) => { mock.pendingEvents = events; @@ -58,6 +59,12 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', async () => { batches: sdkMock.injectedBatches, consume: sdkMock.consumeInjected, }), + useDaemonSessionOwnerGuard: () => ({ + capture: () => { + const version = sdkMock.ownerVersion; + return { isCurrent: () => sdkMock.ownerVersion === version }; + }, + }), subscribePendingPromptEvents: (listener: () => void) => { sdkMock.pendingEventListeners.add(listener); return () => { @@ -83,7 +90,9 @@ const CLIENT_ID = 'client-self'; interface HarnessOptions { connected?: boolean; + writeBlocked?: boolean; sessionId?: string; + workspaceCwd?: string; clientId?: string; canMutateMidTurn?: boolean; canQueryMidTurn?: boolean; @@ -115,7 +124,9 @@ function createHarness() { function TestComponent(opts: HarnessOptions) { latest = useQueuedPrompts({ connected: opts.connected ?? true, + writeBlocked: opts.writeBlocked ?? false, sessionId: opts.sessionId ?? 'session-a', + workspaceCwd: opts.workspaceCwd ?? '/workspace', clientId: opts.clientId ?? CLIENT_ID, canMutateMidTurn: opts.canMutateMidTurn ?? true, canQueryMidTurn: opts.canQueryMidTurn ?? true, @@ -165,6 +176,7 @@ function createHarness() { describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_query)', () => { beforeEach(() => { vi.clearAllMocks(); + sdkMock.ownerVersion = 0; sdkMock.actions.enqueueMidTurnMessage.mockImplementation( (_message: string, opts?: { messageId?: string }) => Promise.resolve({ accepted: true, messageId: opts?.messageId }), @@ -620,7 +632,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que } }); - it('reports an admission failure after the user switches sessions', async () => { + it('does not report an admission failure after the user switches sessions', async () => { let rejectAdmission: ((error: Error) => void) | undefined; sdkMock.actions.enqueueMidTurnMessage.mockReturnValueOnce( new Promise((_resolve, reject) => { @@ -638,7 +650,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que rejectAdmission?.(new Error('daemon unavailable')); }); - expect(harness.reportError).toHaveBeenCalledTimes(1); + expect(harness.reportError).not.toHaveBeenCalled(); expect(harness.editor.setText).not.toHaveBeenCalled(); expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled(); } finally { @@ -879,6 +891,249 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que } }); + it('preserves a stable-id admission across same-session owner replacement', async () => { + sdkMock.actions.enqueueMidTurnMessage.mockReturnValue( + new Promise(() => {}), + ); + const harness = createHarness(); + try { + await harness.render({ streamingState: 'responding' }); + await act(async () => { + harness.result().enqueuePrompt('survive reattach'); + }); + expect(harness.result().queuedPrompts).toEqual([]); + + sdkMock.ownerVersion += 1; + await harness.render({ streamingState: 'responding' }); + + expect(harness.result().queuedPrompts).toEqual([ + expect.objectContaining({ + sessionId: 'session-a', + text: 'survive reattach', + admissionOutcome: 'unknown', + payloadCompleteness: 'complete', + }), + ]); + expect(harness.editor.setText).not.toHaveBeenCalled(); + } finally { + await harness.dispose(); + } + }); + + it('preserves an ambiguous stable-id admission across later reattachment', async () => { + let rejectAdmission: ((error: Error) => void) | undefined; + sdkMock.actions.enqueueMidTurnMessage.mockReturnValue( + new Promise((_resolve, reject) => { + rejectAdmission = reject; + }), + ); + const harness = createHarness(); + try { + await harness.render({ streamingState: 'responding' }); + sdkMock.actions.getMidTurnMessages.mockResolvedValue(undefined); + await act(async () => { + harness.result().enqueuePrompt('ambiguous input'); + rejectAdmission?.(new Error('response lost')); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(harness.result().queuedPrompts).toEqual([ + expect.objectContaining({ + text: 'ambiguous input', + admissionOutcome: 'unknown', + }), + ]); + + sdkMock.ownerVersion += 1; + await harness.render({ streamingState: 'responding' }); + + expect(harness.result().queuedPrompts).toEqual([ + expect.objectContaining({ + text: 'ambiguous input', + admissionOutcome: 'unknown', + }), + ]); + } finally { + await harness.dispose(); + } + }); + + it('does not resurrect an admission after authoritative settlement', async () => { + let rejectAdmission: ((error: Error) => void) | undefined; + sdkMock.actions.enqueueMidTurnMessage.mockReturnValue( + new Promise((_resolve, reject) => { + rejectAdmission = reject; + }), + ); + const harness = createHarness(); + try { + await harness.render({ streamingState: 'responding' }); + sdkMock.actions.getMidTurnMessages.mockResolvedValue(undefined); + await act(async () => { + harness.result().enqueuePrompt('settled input'); + rejectAdmission?.(new Error('response lost')); + await Promise.resolve(); + await Promise.resolve(); + }); + const messageId = harness.result().queuedPrompts[0]?.midTurnMessageId; + if (!messageId) throw new Error('missing stable message id'); + + sdkMock.actions.getMidTurnMessages.mockResolvedValue({ + messages: [], + settledMessageIds: [messageId], + promotedMessageIds: [], + }); + await harness.render({ streamingState: 'idle' }); + expect(harness.result().queuedPrompts).toEqual([]); + + sdkMock.ownerVersion += 1; + await harness.render({ streamingState: 'idle' }); + + expect(harness.result().queuedPrompts).toEqual([]); + } finally { + await harness.dispose(); + } + }); + + it('does not carry a stable-id admission into another workspace', async () => { + sdkMock.actions.enqueueMidTurnMessage.mockReturnValue( + new Promise(() => {}), + ); + const harness = createHarness(); + try { + await harness.render({ + streamingState: 'responding', + workspaceCwd: '/workspace-a', + }); + await act(async () => { + harness.result().enqueuePrompt('workspace-a input'); + }); + + await harness.render({ + streamingState: 'responding', + workspaceCwd: '/workspace-b', + }); + + expect(harness.result().queuedPrompts).toEqual([]); + expect(harness.editor.setText).not.toHaveBeenCalled(); + + await harness.render({ + streamingState: 'responding', + workspaceCwd: '/workspace-a', + }); + expect(harness.result().queuedPrompts).toEqual([ + expect.objectContaining({ + text: 'workspace-a input', + admissionOutcome: 'unknown', + }), + ]); + } finally { + await harness.dispose(); + } + }); + + it('restores a rejected stable-id admission after returning to its workspace', async () => { + let resolveAdmission: + | ((value: { accepted: boolean; messageId?: string }) => void) + | undefined; + sdkMock.actions.enqueueMidTurnMessage.mockReturnValue( + new Promise((resolve) => { + resolveAdmission = resolve; + }), + ); + const harness = createHarness(); + try { + await harness.render({ + streamingState: 'responding', + workspaceCwd: '/workspace-a', + }); + await act(async () => { + harness.result().enqueuePrompt('rejected in workspace-a'); + }); + await harness.render({ + streamingState: 'responding', + workspaceCwd: '/workspace-b', + }); + await act(async () => { + resolveAdmission?.({ accepted: false }); + await Promise.resolve(); + }); + + await harness.render({ + streamingState: 'responding', + workspaceCwd: '/workspace-a', + }); + + expect(harness.result().queuedPrompts).toEqual([ + expect.objectContaining({ + text: 'rejected in workspace-a', + admissionOutcome: 'unknown', + }), + ]); + } finally { + await harness.dispose(); + } + }); + + it('does not apply an old-owner reconcile after same-id reattachment', async () => { + const harness = createHarness(); + try { + await harness.render({ streamingState: 'responding' }); + let resolveSnapshot: ((value: unknown) => void) | undefined; + sdkMock.actions.getMidTurnMessages.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ); + await harness.render({ streamingState: 'idle' }); + + sdkMock.ownerVersion += 1; + await harness.render({ streamingState: 'idle' }); + resolveSnapshot?.({ + messages: [{ messageId: 'stale', text: 'old owner payload' }], + settledMessageIds: [], + promotedMessageIds: [], + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(harness.result().queuedPrompts).toEqual([]); + } finally { + await harness.dispose(); + } + }); + + it('does not fall back after an idle reconciliation is blocked', async () => { + const harness = createHarness(); + try { + await harness.render({ streamingState: 'responding' }); + sdkMock.actions.getPendingPrompts.mockClear(); + sdkMock.actions.getMidTurnMessages.mockImplementationOnce( + (opts?: { signal?: AbortSignal }) => + new Promise((resolve) => { + opts?.signal?.addEventListener('abort', () => resolve(undefined), { + once: true, + }); + }), + ); + + await harness.render({ streamingState: 'idle', writeBlocked: false }); + await harness.render({ streamingState: 'idle', writeBlocked: true }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(sdkMock.actions.getPendingPrompts).not.toHaveBeenCalled(); + expect(harness.result().queuedPrompts).toEqual([]); + } finally { + await harness.dispose(); + } + }); + it('drops a connect snapshot after the streaming phase changes', async () => { const resolveSnapshots: Array<(value: unknown) => void> = []; sdkMock.actions.getMidTurnMessages.mockImplementation( diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index fded8976577..cc3b3f789f8 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -8,7 +8,6 @@ import { useCallback, useEffect, useLayoutEffect, - useMemo, useRef, useState, useSyncExternalStore, @@ -20,6 +19,7 @@ import { subscribePendingPromptEvents, subscribePendingPromptVersion, useDaemonMidTurnInjected, + useDaemonSessionOwnerGuard, type DaemonSessionActions, type DaemonStreamingState, } from '@qwen-code/webui/daemon-react-sdk'; @@ -43,7 +43,9 @@ interface RefBox { interface UseQueuedPromptsArgs { connected: boolean; + writeBlocked?: boolean; sessionId?: string; + workspaceCwd?: string; clientId?: string; /** * Whether the daemon advertises `session_mid_turn_message_mutation`. Gates the @@ -149,7 +151,9 @@ export interface UseQueuedPromptsResult { export function useQueuedPrompts({ connected, + writeBlocked = false, sessionId, + workspaceCwd, clientId, canMutateMidTurn, canQueryMidTurn, @@ -160,14 +164,36 @@ export function useQueuedPrompts({ reportError, t, }: UseQueuedPromptsArgs): UseQueuedPromptsResult { + const writeBlockedRef = useRef(writeBlocked); + writeBlockedRef.current = writeBlocked; + const sessionOwnerGuard = useDaemonSessionOwnerGuard(); const [queuedPrompts, setQueuedPrompts] = useState([]); const queuedPromptsRef = useRef([]); - const ownerTokenRef = useRef({ sessionId }); - if (ownerTokenRef.current.sessionId !== sessionId) { - ownerTokenRef.current = { sessionId }; + const ownerTokenRef = useRef({ + sessionId, + workspaceCwd, + snapshot: sessionOwnerGuard.capture(), + }); + if ( + ownerTokenRef.current.sessionId !== sessionId || + ownerTokenRef.current.workspaceCwd !== workspaceCwd || + !ownerTokenRef.current.snapshot.isCurrent() + ) { + ownerTokenRef.current = { + sessionId, + workspaceCwd, + snapshot: sessionOwnerGuard.capture(), + }; } + const ownerToken = ownerTokenRef.current; + const isCurrentOwnerTokenRef = useRef( + (token: typeof ownerToken) => + ownerTokenRef.current === token && token.snapshot.isCurrent(), + ); + const queuedPromptsOwnerRef = useRef(ownerToken); const nextQueuedPromptIdRef = useRef(1); const latestSessionIdRef = useRef(sessionId); + const latestWorkspaceCwdRef = useRef(workspaceCwd); const midTurnEnqueueAbortRef = useRef(null); const submitAbortControllersRef = useRef>(new Set()); const removingServerPromptIdsRef = useRef>(new Set()); @@ -175,6 +201,9 @@ export function useQueuedPrompts({ const completionCallbacksRef = useRef void>>(new Map()); const completedPromptIdsRef = useRef>(new Set()); const completedPromptIdOrderRef = useRef([]); + const pendingMidTurnAdmissionsRef = useRef< + Map + >(new Map()); const appendedBeforeResponsePromptIdsRef = useRef>(new Set()); const removedBeforeResponsePromptIdsRef = useRef>(new Set()); const latestStreamingStateRef = useRef(streamingState); @@ -198,42 +227,21 @@ export function useQueuedPrompts({ }, []); latestSessionIdRef.current = sessionId; + latestWorkspaceCwdRef.current = workspaceCwd; const streamingIdle = streamingState === 'idle'; useLayoutEffect(() => { midTurnReconcileSeqRef.current += 1; }, [streamingIdle]); latestStreamingStateRef.current = streamingState; - const queuedTexts = useMemo( - () => queuedPrompts.map((prompt) => prompt.text), - [queuedPrompts], - ); + const visibleQueuedPrompts = + queuedPromptsOwnerRef.current === ownerToken ? queuedPrompts : []; + const queuedTexts = visibleQueuedPrompts.map((prompt) => prompt.text); useEffect(() => { queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); - useEffect(() => { - queuedPromptsRef.current = []; - setQueuedPrompts([]); - completionCallbacksRef.current = new Map(); - completedPromptIdsRef.current = new Set(); - completedPromptIdOrderRef.current = []; - appendedBeforeResponsePromptIdsRef.current = new Set(); - removedBeforeResponsePromptIdsRef.current = new Set(); - for (const controller of submitAbortControllersRef.current) { - controller.abort(); - } - submitAbortControllersRef.current.clear(); - removingServerPromptIdsRef.current = new Set(); - displayedServerPromptIdsRef.current = new Set(); - restoredPromptIdsRef.current = new Set(); - pendingStartedByPromptIdRef.current = new Map(); - initialRefreshSessionIdRef.current = undefined; - midTurnEnqueueAbortRef.current?.abort(); - midTurnEnqueueAbortRef.current = null; - }, [sessionId]); - const settleCompletionCallback = useCallback( (promptId: string, onComplete: () => void) => { if (completedPromptIdsRef.current.delete(promptId)) { @@ -347,7 +355,7 @@ export function useQueuedPrompts({ }); if (requestSeq !== refreshRequestSeqRef.current) return 'superseded'; if ( - ownerTokenRef.current !== ownerToken || + !isCurrentOwnerTokenRef.current(ownerToken) || latestSessionIdRef.current !== targetSessionId ) { return 'skipped'; @@ -375,11 +383,18 @@ export function useQueuedPrompts({ ): Set => { const settledIds = new Set(snapshot.settledMessageIds); const promotedIds = new Set(snapshot.promotedMessageIds); + for (const message of snapshot.messages) { + pendingMidTurnAdmissionsRef.current.delete(message.messageId); + } for (const messageId of settledIds) { + pendingMidTurnAdmissionsRef.current.delete(messageId); const callback = completionCallbacksRef.current.get(messageId); completionCallbacksRef.current.delete(messageId); callback?.(); } + for (const messageId of promotedIds) { + pendingMidTurnAdmissionsRef.current.delete(messageId); + } const waitingIds = new Set( snapshot.messages.map((message) => message.messageId), ); @@ -484,7 +499,11 @@ export function useQueuedPrompts({ opts?: { signal?: AbortSignal; seq?: number }, ): Promise => { const expectedSeq = opts?.seq ?? ++midTurnReconcileSeqRef.current; + const expectedOwnerToken = ownerTokenRef.current; const isCurrent = () => + !opts?.signal?.aborted && + !writeBlockedRef.current && + isCurrentOwnerTokenRef.current(expectedOwnerToken) && latestSessionIdRef.current === targetSessionId && expectedSeq === midTurnReconcileSeqRef.current; if (!isCurrent()) return undefined; @@ -540,7 +559,7 @@ export function useQueuedPrompts({ expectedOwnerToken = ownerTokenRef.current, ): boolean => { if ( - ownerTokenRef.current !== expectedOwnerToken || + !isCurrentOwnerTokenRef.current(expectedOwnerToken) || (targetSessionId !== undefined && latestSessionIdRef.current !== targetSessionId) ) { @@ -598,6 +617,54 @@ export function useQueuedPrompts({ }, [editorRef], ); + const restoreQueuedPromptsToEditorRef = useRef(restoreQueuedPromptsToEditor); + restoreQueuedPromptsToEditorRef.current = restoreQueuedPromptsToEditor; + + useEffect(() => { + restoredPromptIdsRef.current = new Set(); + const retainedAdmissions = [ + ...pendingMidTurnAdmissionsRef.current.entries(), + ].filter( + ([, entry]) => + entry.prompt.sessionId === sessionId && + entry.workspaceCwd === workspaceCwd, + ); + const retainedAdmissionIds = new Set( + retainedAdmissions.map(([messageId]) => messageId), + ); + const retainedCompletionCallbacks = new Map( + [...completionCallbacksRef.current.entries()].filter(([promptId]) => + retainedAdmissionIds.has(promptId), + ), + ); + const interruptedPrompts = queuedPromptsRef.current.filter( + (prompt) => + prompt.midTurnState === 'submitting' || + prompt.midTurnFailedAction === 'edit', + ); + if (interruptedPrompts.length > 0) { + restoreQueuedPromptsToEditorRef.current(interruptedPrompts); + } + queuedPromptsOwnerRef.current = ownerToken; + const retainedPrompts = retainedAdmissions.map(([, entry]) => entry.prompt); + queuedPromptsRef.current = retainedPrompts; + setQueuedPrompts(retainedPrompts); + completionCallbacksRef.current = retainedCompletionCallbacks; + completedPromptIdsRef.current = new Set(); + completedPromptIdOrderRef.current = []; + appendedBeforeResponsePromptIdsRef.current = new Set(); + removedBeforeResponsePromptIdsRef.current = new Set(); + for (const controller of submitAbortControllersRef.current) { + controller.abort(); + } + submitAbortControllersRef.current.clear(); + removingServerPromptIdsRef.current = new Set(); + displayedServerPromptIdsRef.current = new Set(); + pendingStartedByPromptIdRef.current = new Map(); + initialRefreshSessionIdRef.current = undefined; + midTurnEnqueueAbortRef.current?.abort(); + midTurnEnqueueAbortRef.current = null; + }, [ownerToken, sessionId, workspaceCwd]); const appendLocalQueuedPrompt = useCallback( (prompt: QueuedPrompt, promptId: string) => { @@ -654,6 +721,7 @@ export function useQueuedPrompts({ sessionId, streamingState, canQueryMidTurn, + ownerToken, refreshPendingPrompts, reconcileMidTurnMessages, ]); @@ -671,6 +739,7 @@ export function useQueuedPrompts({ handled.push(event); const promptId = event.data.promptId; if (!promptId) continue; + pendingMidTurnAdmissionsRef.current.delete(promptId); if (event.type === 'pending_prompt_started') { if (removingServerPromptIdsRef.current.has(promptId)) { continue; @@ -798,7 +867,7 @@ export function useQueuedPrompts({ .then((result) => { submitAbortControllersRef.current.delete(submitAbort); if ( - ownerTokenRef.current !== ownerToken || + !isCurrentOwnerTokenRef.current(ownerToken) || latestSessionIdRef.current !== targetSessionId ) { return; @@ -920,7 +989,7 @@ export function useQueuedPrompts({ .catch((error: unknown) => { submitAbortControllersRef.current.delete(submitAbort); if ( - ownerTokenRef.current !== ownerToken || + !isCurrentOwnerTokenRef.current(ownerToken) || latestSessionIdRef.current !== targetSessionId ) { return; @@ -966,6 +1035,7 @@ export function useQueuedPrompts({ const fallbackToPendingPrompt = useCallback( (id: number) => { + if (writeBlockedRef.current) return; const current = queuedPromptsRef.current; const index = current.findIndex( (prompt) => prompt.id === id && prompt.midTurnState !== undefined, @@ -1000,6 +1070,7 @@ export function useQueuedPrompts({ const trimmed = text.trim(); if (!trimmed && (images?.length ?? 0) === 0) return true; const targetSessionId = latestSessionIdRef.current; + const targetWorkspaceCwd = latestWorkspaceCwdRef.current; const ownerToken = ownerTokenRef.current; const shouldInsertMidTurn = latestStreamingStateRef.current !== 'idle' && @@ -1017,6 +1088,19 @@ export function useQueuedPrompts({ : undefined; if (shouldInsertMidTurn && canQueryMidTurn && midTurnMessageId) { + const pendingAdmission: QueuedPrompt = { + id: nextQueuedPromptIdRef.current++, + sessionId: targetSessionId, + text: trimmed, + midTurnMessageId, + admissionOutcome: 'unknown', + payloadCompleteness: 'complete', + payloadAvailable: true, + }; + pendingMidTurnAdmissionsRef.current.set(midTurnMessageId, { + prompt: pendingAdmission, + workspaceCwd: targetWorkspaceCwd, + }); if (onComplete) { settleCompletionCallback(midTurnMessageId, onComplete); } @@ -1025,20 +1109,25 @@ export function useQueuedPrompts({ .then(async (result) => { if (!result.accepted) { completionCallbacksRef.current.delete(midTurnMessageId); - if (latestSessionIdRef.current === targetSessionId) { - restoreQueuedPromptsToEditor( - [ - { - id: nextQueuedPromptIdRef.current++, - sessionId: targetSessionId, - text: trimmed, - images: images ? [...images] : undefined, - payloadCompleteness: 'complete', - }, - ], - targetSessionId, - ); + if ( + latestSessionIdRef.current !== targetSessionId || + latestWorkspaceCwdRef.current !== targetWorkspaceCwd + ) { + return; } + const pendingAdmissionStillOwned = + pendingMidTurnAdmissionsRef.current.delete(midTurnMessageId); + if (!pendingAdmissionStillOwned) return; + const next = queuedPromptsRef.current.filter( + (prompt) => prompt.midTurnMessageId !== midTurnMessageId, + ); + queuedPromptsRef.current = next; + setQueuedPrompts(next); + restoreQueuedPromptsToEditor( + [pendingAdmission], + targetSessionId, + true, + ); reportError( new Error('Daemon rejected mid-turn message'), t('queue.queueFailed'), @@ -1047,21 +1136,26 @@ export function useQueuedPrompts({ } if ( latestSessionIdRef.current === targetSessionId && + latestWorkspaceCwdRef.current === targetWorkspaceCwd && targetSessionId ) { await reconcileMidTurnMessages(targetSessionId); } + pendingMidTurnAdmissionsRef.current.delete(midTurnMessageId); }) .catch(async (error: unknown) => { if ( latestSessionIdRef.current !== targetSessionId || + latestWorkspaceCwdRef.current !== targetWorkspaceCwd || !targetSessionId ) { - reportError(error, t('queue.queueFailed')); return; } const snapshot = await reconcileMidTurnMessages(targetSessionId); if (!snapshot) { + if (!pendingMidTurnAdmissionsRef.current.has(midTurnMessageId)) { + return; + } if ( !queuedPromptsRef.current.some( (prompt) => @@ -1087,13 +1181,15 @@ export function useQueuedPrompts({ reportError(error, t('queue.admissionUnknown')); return; } + const pendingAdmissionStillOwned = + pendingMidTurnAdmissionsRef.current.delete(midTurnMessageId); const known = snapshot.messages.some( (message) => message.messageId === midTurnMessageId, ) || snapshot.settledMessageIds.includes(midTurnMessageId) || snapshot.promotedMessageIds.includes(midTurnMessageId); - if (known) return; + if (known || !pendingAdmissionStillOwned) return; completionCallbacksRef.current.delete(midTurnMessageId); restoreQueuedPromptsToEditor( [ @@ -1142,7 +1238,7 @@ export function useQueuedPrompts({ signal: abort.signal, }) .then((result) => { - if (ownerTokenRef.current !== ownerToken) return; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return; const current = queuedPromptsRef.current; const index = current.findIndex((item) => item.id === prompt.id); if (index === -1) return; @@ -1187,6 +1283,7 @@ export function useQueuedPrompts({ for (const batch of midTurnInjectedBatches) { if (batch.sessionId !== sessionId) continue; for (const messageId of batch.messageIds ?? []) { + pendingMidTurnAdmissionsRef.current.delete(messageId); const callback = completionCallbacksRef.current.get(messageId); completionCallbacksRef.current.delete(messageId); callback?.(); @@ -1220,9 +1317,12 @@ export function useQueuedPrompts({ ]); useEffect(() => { - if (streamingState !== 'idle') return; - midTurnEnqueueAbortRef.current?.abort(); - midTurnEnqueueAbortRef.current = null; + if (streamingState !== 'idle' || writeBlocked) return; + const ctrl = midTurnEnqueueAbortRef.current; + if (ctrl) { + ctrl.abort(); + midTurnEnqueueAbortRef.current = null; + } for (const prompt of queuedPromptsRef.current) { if (!prompt.midTurnFailedAction) continue; const next = queuedPromptsRef.current.filter( @@ -1262,6 +1362,7 @@ export function useQueuedPrompts({ }; }, [ streamingState, + writeBlocked, canQueryMidTurn, fallbackToPendingPrompt, restoreQueuedPromptsToEditor, @@ -1327,15 +1428,15 @@ export function useQueuedPrompts({ sessionId: targetSessionId, }, ); - if (ownerTokenRef.current !== ownerToken) return false; removingPromptIds.delete(target.serverPromptId); + if (!isCurrentOwnerTokenRef.current(ownerToken)) return result.removed; if (!result.removed) { setQueuedPromptFlags(target.id, { isEditing: false, isRemoving: false, }); await refreshPendingPrompts(targetSessionId); - if (ownerTokenRef.current !== ownerToken) return false; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return false; reportError( new Error('Prompt could not be removed from queue'), fallback, @@ -1344,7 +1445,7 @@ export function useQueuedPrompts({ } completionCallbacksRef.current.delete(target.serverPromptId); const refreshResult = await refreshPendingPrompts(targetSessionId); - if (ownerTokenRef.current !== ownerToken) return false; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return true; if (refreshResult === 'failed') { setQueuedPromptFlags(target.id, { isEditing: false, @@ -1357,14 +1458,14 @@ export function useQueuedPrompts({ } return true; } catch (error) { - if (ownerTokenRef.current !== ownerToken) return false; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return false; removingPromptIds.delete(target.serverPromptId); setQueuedPromptFlags(target.id, { isEditing: false, isRemoving: false, }); const refreshResult = await refreshPendingPrompts(targetSessionId); - if (ownerTokenRef.current !== ownerToken) return false; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return false; if (refreshResult !== 'refreshed') { restoreQueuedPrompts([target]); } @@ -1408,7 +1509,7 @@ export function useQueuedPrompts({ target.midTurnMessageId, { sessionId: target.sessionId }, ); - if (ownerTokenRef.current !== ownerToken) return false; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return result.removed; const current = queuedPromptsRef.current; const latest = current.find((prompt) => prompt.id === target.id); if (!latest) return result.removed; @@ -1456,7 +1557,7 @@ export function useQueuedPrompts({ setQueuedPrompts(next); return true; } catch (error) { - if (ownerTokenRef.current !== ownerToken) return false; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return false; const latest = queuedPromptsRef.current.find( (prompt) => prompt.id === target.id, ); @@ -1558,9 +1659,10 @@ export function useQueuedPrompts({ ) { return false; } - if (ownerTokenRef.current !== ownerToken) return false; + if (!isCurrentOwnerTokenRef.current(ownerToken)) return false; if (target.midTurnMessageId) { completionCallbacksRef.current.delete(target.midTurnMessageId); + pendingMidTurnAdmissionsRef.current.delete(target.midTurnMessageId); } const next = queuedPromptsRef.current.map((prompt) => prompt.id === id @@ -1592,7 +1694,6 @@ export function useQueuedPrompts({ const editQueuedPrompt = useCallback( async (id: number) => { - const editOwnerToken = ownerTokenRef.current; const target = queuedPromptsRef.current.find((p) => p.id === id); if (!target || target.serverState === 'submitting') return; if ( @@ -1609,12 +1710,7 @@ export function useQueuedPrompts({ t('queue.editFailed'), ); if (removed) { - restoreQueuedPromptsToEditor( - [target], - target.sessionId, - false, - editOwnerToken, - ); + restoreQueuedPromptsToEditor([target]); } return; } @@ -1625,12 +1721,7 @@ export function useQueuedPrompts({ t('queue.editFailed'), ); if (!removed) return; - restoreQueuedPromptsToEditor( - [target], - target.sessionId, - false, - editOwnerToken, - ); + restoreQueuedPromptsToEditor([target]); return; } const popped = popQueuedPromptForEdit(id); @@ -1673,7 +1764,6 @@ export function useQueuedPrompts({ return true; } if (target.serverState !== 'queued') return false; - const editOwnerToken = ownerTokenRef.current; void (async () => { const removed = await removeServerPromptForAction( target, @@ -1681,12 +1771,7 @@ export function useQueuedPrompts({ t('queue.editFailed'), ); if (removed) { - restoreQueuedPromptsToEditor( - [target], - target.sessionId, - false, - editOwnerToken, - ); + restoreQueuedPromptsToEditor([target]); } })().catch((error: unknown) => { reportError(error, t('queue.editFailed')); @@ -1797,7 +1882,7 @@ export function useQueuedPrompts({ ); if ( - ownerTokenRef.current !== clearOwnerToken || + !isCurrentOwnerTokenRef.current(clearOwnerToken) || latestSessionIdRef.current !== clearSessionId ) { return; @@ -1831,7 +1916,7 @@ export function useQueuedPrompts({ }, [refreshPendingPrompts, reportError, store, t, sessionActions]); return { - queuedPrompts, + queuedPrompts: visibleQueuedPrompts, queuedTexts, enqueuePrompt, removeQueuedPrompt, diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx index 56d4a0cba1c..61abb064267 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx +++ b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx @@ -23,6 +23,8 @@ interface Deferred { } const sdkMock = vi.hoisted(() => ({ + ownerVersion: 0, + ownerGuard: { capture: vi.fn() }, actions: { loadArtifacts: vi.fn(), }, @@ -39,6 +41,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useActions: () => sdkMock.actions, useConnection: () => sdkMock.connection, usePromptStatus: () => sdkMock.promptStatus, + useDaemonSessionOwnerGuard: () => sdkMock.ownerGuard, useWorkspaceEventSignals: () => ({ artifactsVersion: sdkMock.artifactsVersion, }), @@ -101,6 +104,11 @@ beforeEach(() => { }; sdkMock.promptStatus = 'idle'; sdkMock.artifactsVersion = 0; + sdkMock.ownerVersion = 0; + sdkMock.ownerGuard.capture.mockImplementation(() => { + const version = sdkMock.ownerVersion; + return { isCurrent: () => sdkMock.ownerVersion === version }; + }); sdkMock.actions.loadArtifacts.mockReset(); }); @@ -137,6 +145,7 @@ describe('useSessionArtifacts', () => { sessionId: 'session-b', capabilities: { features: ['session_artifacts'] }, }; + sdkMock.ownerVersion += 1; await rerenderHookHost(); expect(latestState?.loading).toBe(true); @@ -180,4 +189,30 @@ describe('useSessionArtifacts', () => { 'refreshed-artifact', ]); }); + + it('loads a same-id replacement without waiting for the old owner', async () => { + const oldLoad = deferred<{ artifacts: DaemonSessionArtifact[] }>(); + sdkMock.actions.loadArtifacts + .mockReturnValueOnce(oldLoad.promise) + .mockResolvedValueOnce({ artifacts: [artifact('replacement')] }); + + await renderHookHost(); + expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledOnce(); + + sdkMock.ownerVersion += 1; + await rerenderHookHost(); + + expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(2); + expect(latestState?.artifacts.map((item) => item.id)).toEqual([ + 'replacement', + ]); + + await act(async () => { + oldLoad.resolve({ artifacts: [artifact('stale')] }); + await oldLoad.promise; + }); + expect(latestState?.artifacts.map((item) => item.id)).toEqual([ + 'replacement', + ]); + }); }); diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.ts b/packages/web-shell/client/hooks/useSessionArtifacts.ts index 5f834b90c9f..da62d040a4a 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.ts +++ b/packages/web-shell/client/hooks/useSessionArtifacts.ts @@ -3,6 +3,7 @@ import { useActions, useConnection, usePromptStatus, + useDaemonSessionOwnerGuard, useWorkspaceEventSignals, } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; @@ -20,6 +21,10 @@ export interface SessionArtifactsState { export function useSessionArtifacts(): SessionArtifactsState { const actions = useActions(); const connection = useConnection(); + const ownerGuard = useDaemonSessionOwnerGuard(); + const ownerRef = useRef(ownerGuard.capture()); + if (!ownerRef.current?.isCurrent()) ownerRef.current = ownerGuard.capture(); + const owner = ownerRef.current; const promptStatus = usePromptStatus(); const workspaceEventSignals = useWorkspaceEventSignals(); const artifactsVersion = workspaceEventSignals?.artifactsVersion; @@ -30,58 +35,37 @@ export function useSessionArtifacts(): SessionArtifactsState { const sessionId = connection.sessionId; const [artifacts, setArtifacts] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const requestIdRef = useRef(0); - const loadedSessionIdRef = useRef(undefined); + const loadedOwnerRef = useRef(undefined); + const loadingOwnerRef = useRef(undefined); const previousPromptStatusRef = useRef(promptStatus); const previousArtifactsVersionRef = useRef(artifactsVersion); const refresh = useCallback(async () => { - const requestId = requestIdRef.current + 1; - requestIdRef.current = requestId; - if (!sessionId) { - loadedSessionIdRef.current = undefined; + const requestId = ++requestIdRef.current; + if (!sessionId || !isConnected || !supportsArtifacts) { + loadedOwnerRef.current = undefined; + loadingOwnerRef.current = undefined; setArtifacts([]); - setError(null); setLoading(false); return; } - if (!isConnected) { - loadedSessionIdRef.current = undefined; - setArtifacts([]); - setError(null); - setLoading(false); - return; - } - if (!supportsArtifacts) { - loadedSessionIdRef.current = undefined; - setArtifacts([]); - setError(null); - setLoading(false); - return; - } - if ( - loadedSessionIdRef.current !== undefined && - loadedSessionIdRef.current !== sessionId - ) { - setArtifacts([]); - } + if (loadedOwnerRef.current !== owner) setArtifacts([]); + loadingOwnerRef.current = owner; setLoading(true); try { const result = await actions.loadArtifacts(); - if (requestIdRef.current !== requestId) return; - loadedSessionIdRef.current = sessionId; + if (requestIdRef.current !== requestId || !owner.isCurrent()) return; + loadedOwnerRef.current = owner; setArtifacts(result.artifacts); - setError(null); } catch { - if (requestIdRef.current !== requestId) return; - setError(null); + // The artifacts panel treats a failed refresh as an empty error state. } finally { if (requestIdRef.current === requestId) { setLoading(false); } } - }, [actions, isConnected, sessionId, supportsArtifacts]); + }, [actions, isConnected, owner, sessionId, supportsArtifacts]); const refreshRef = useRef(refresh); refreshRef.current = refresh; @@ -110,9 +94,22 @@ export function useSessionArtifacts(): SessionArtifactsState { }, [artifactsVersion]); const artifactById = useMemo( - () => new Map(artifacts.map((artifact) => [artifact.id, artifact])), - [artifacts], + () => + new Map( + (loadedOwnerRef.current === owner ? artifacts : []).map((artifact) => [ + artifact.id, + artifact, + ]), + ), + [artifacts, owner], ); - return { artifacts, artifactById, loading, error, refresh }; + const visibleArtifacts = loadedOwnerRef.current === owner ? artifacts : []; + return { + artifacts: visibleArtifacts, + artifactById, + loading: loading && loadingOwnerRef.current === owner, + error: null, + refresh, + }; } diff --git a/packages/web-shell/client/index.test.tsx b/packages/web-shell/client/index.test.tsx index 51678aefa40..702ae913354 100644 --- a/packages/web-shell/client/index.test.tsx +++ b/packages/web-shell/client/index.test.tsx @@ -10,6 +10,7 @@ let workspaceShouldThrow = false; const sessionProviderProps: Array> = []; const appProps: Array> = []; let workspaceCapabilities: { + features: string[]; workspaceCwd?: string; workspaces?: Array<{ id: string; @@ -18,6 +19,7 @@ let workspaceCapabilities: { trusted?: boolean; }>; } = { + features: [], workspaceCwd: '/workspace', workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; @@ -44,6 +46,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', async () => { refreshCapabilities, }), useWorkspaceActions: () => ({ addWorkspace }), + useConnection: () => ({ status: 'idle' }), }; }); vi.mock('./App', async () => { @@ -92,6 +95,7 @@ afterEach(() => { sessionProviderProps.length = 0; appProps.length = 0; workspaceCapabilities = { + features: [], workspaceCwd: '/workspace', workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; @@ -144,6 +148,7 @@ describe('WebShellWithProviders top-level boundary', () => { it('selects a registered workspace by path without locking the UI', () => { workspaceCapabilities = { + features: [], workspaces: [ { id: 'primary', cwd: '/workspace', primary: true }, { id: 'secondary', cwd: '/work/secondary', primary: false }, @@ -166,6 +171,7 @@ describe('WebShellWithProviders top-level boundary', () => { it('initializes an unlocked workspace selector from workspace id', () => { workspaceCapabilities = { + features: [], workspaces: [ { id: 'primary', cwd: '/workspace', primary: true }, { id: 'secondary', cwd: '/work/secondary', primary: false }, @@ -195,6 +201,7 @@ describe('WebShellWithProviders top-level boundary', () => { it('locks directly to an already registered workspace path', () => { workspaceCapabilities = { + features: [], workspaces: [ { id: 'primary', cwd: '/workspace', primary: true }, { id: 'secondary', cwd: '/work/secondary', primary: false }, @@ -225,7 +232,7 @@ describe('WebShellWithProviders top-level boundary', () => { }); it('recognizes the primary path when single-workspace capabilities omit workspaces', () => { - workspaceCapabilities = { workspaceCwd: '/workspace' }; + workspaceCapabilities = { features: [], workspaceCwd: '/workspace' }; render(); @@ -243,7 +250,7 @@ describe('WebShellWithProviders top-level boundary', () => { }); it('selects the primary path without locking when workspaces are omitted', () => { - workspaceCapabilities = { workspaceCwd: '/workspace' }; + workspaceCapabilities = { features: [], workspaceCwd: '/workspace' }; render(); diff --git a/packages/webui/README.md b/packages/webui/README.md index 4e2c8c442c5..f30d7c79553 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -247,16 +247,17 @@ Do NOT nest multiple `` for the same session — that cre ### Session hooks -| Hook | Returns | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `useTranscriptBlocks()` | `readonly DaemonTranscriptBlock[]` (raw blocks) | -| `useTranscriptState()` | Full `DaemonTranscriptState` (blocks + metadata) | -| `useActions()` | `{ sendPrompt, cancel, setModel, setApprovalMode, respondToPermission, loadSession, newSession, ... }` | -| `useConnection()` | `{ status, sessionId, currentModel, currentMode, commands, skills, models, tokenCount, tokenUsage, contextWindow }` | -| `useStreamingState()` | `'idle' \| 'waiting' \| 'responding' \| 'thinking'` | -| `usePromptStatus()` | `'idle' \| 'waiting' \| 'streaming'` | -| `usePendingPermissions()` | Unresolved permission blocks | -| `useActiveTodoList()` | Latest todo list, only when it still has active items | +| Hook | Returns | +| ------------------------------ | ------------------------------------------------------------------------------------------------------- | +| `useTranscriptBlocks()` | `readonly DaemonTranscriptBlock[]` (raw blocks) | +| `useTranscriptState()` | Full `DaemonTranscriptState` (blocks + metadata) | +| `useActions()` | `{ sendPrompt, cancel, setModel, setApprovalMode, respondToPermission, loadSession, newSession, ... }` | +| `useConnection()` | Connection metadata, including optional `sessionTransition` state for transactional cross-session loads | +| `useDaemonSessionOwnerGuard()` | Captures the current attachment identity so stale async UI continuations can be ignored | +| `useStreamingState()` | `'idle' \| 'waiting' \| 'responding' \| 'thinking'` | +| `usePromptStatus()` | `'idle' \| 'waiting' \| 'streaming'` | +| `usePendingPermissions()` | Unresolved permission blocks | +| `useActiveTodoList()` | Latest todo list, only when it still has active items | ### Workspace hooks @@ -281,18 +282,19 @@ All resource hooks accept `{ autoLoad?: boolean, enabled?: boolean }` and return **`DaemonSessionProviderProps`:** -| Prop | Type | Default | Description | -| --------------------- | --------- | --------- | -------------------------------------------------------------------------------------------------------- | -| `baseUrl` | `string?` | inherited | Daemon HTTP base URL (inherited from `DaemonWorkspaceProvider` when nested; required in standalone mode) | -| `token` | `string?` | inherited | Bearer token (inherited from `DaemonWorkspaceProvider` when nested) | -| `workspaceCwd` | `string?` | — | Override workspace path (uses capabilities if omitted) | -| `initialSessionId` | `string?` | — | Restore a specific session on mount | -| `clientId` | `string?` | — | Override stable client ID (auto-generated if omitted) | -| `autoConnect` | `boolean` | `true` | Connect on mount | -| `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect | -| `reconnectDelayMs` | `number` | `1000` | Initial reconnect backoff | -| `maxReconnectDelayMs` | `number` | `10000` | Max reconnect backoff | -| `suppressOwnUserEcho` | `boolean` | `true` | Suppress own user message echoes | +| Prop | Type | Default | Description | +| --------------------------- | ------------------ | --------- | -------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string?` | inherited | Daemon HTTP base URL (inherited from `DaemonWorkspaceProvider` when nested; required in standalone mode) | +| `token` | `string?` | inherited | Bearer token (inherited from `DaemonWorkspaceProvider` when nested) | +| `workspaceCwd` | `string?` | — | Override workspace path (uses capabilities if omitted) | +| `sessionId` | `string?` | — | Restore a specific session and control later session transitions | +| `clientId` | `string?` | — | Override stable client ID (auto-generated if omitted) | +| `autoConnect` | `boolean` | `true` | Connect on mount | +| `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect | +| `reconnectDelayMs` | `number` | `1000` | Initial reconnect backoff | +| `maxReconnectDelayMs` | `number` | `10000` | Max reconnect backoff | +| `suppressOwnUserEcho` | `boolean` | `true` | Suppress own user message echoes | +| `onSessionTransitionCommit` | `(target) => void` | — | Runs synchronously after a transactional target becomes the owner and before the load promise resolves | **`DaemonWorkspaceProviderProps`:** diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index 154884e7643..f92c4fa07f6 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -46,6 +46,8 @@ export { useDaemonActions as useActions } from './daemon/index.js'; /** Connection status, capabilities, and model info. */ export { useDaemonConnection as useConnection } from './daemon/index.js'; +export { useDaemonSessionOwnerGuard } from './daemon/session/DaemonSessionProvider.js'; + /** Current session metadata (id, model, approval mode). */ export { useDaemonSession as useSession } from './daemon/index.js'; @@ -242,6 +244,11 @@ export type { /** Result of non-blocking `submitPrompt()`: the daemon-assigned promptId. */ SubmitPromptResult, } from './daemon/index.js'; +export type { + DaemonSessionOwnerGuard, + DaemonSessionOwnerSnapshot, + DaemonSessionTransition, +} from './daemon/session/types.js'; // ── Types: Todos ───────────────────────────────────────────────── diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index f245e772746..e6720d4715e 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -24,6 +24,7 @@ import { useDaemonActions, useDaemonConnection, useDaemonSessionNotices, + useDaemonSessionOwnerGuard, useDaemonPendingPermissions, useDaemonPromptStatus, useDaemonStreamingState, @@ -46,6 +47,12 @@ import { clearSidechannelMidTurnInjected, getSidechannelMidTurnInjected, } from '../midTurnInjectedSidechannel.js'; +import { + clearSidechannelFollowupSuggestion, + getSidechannelFollowupSuggestion, + publishSidechannelFollowupSuggestion, + subscribeSidechannelFollowupSuggestion, +} from '../followupSidechannel.js'; import { persistStableClientId } from './clientLifecycle.js'; interface MockSession { @@ -109,6 +116,7 @@ interface MockClient { workspaceProviders: () => Promise; listWorkspaceSessions: () => Promise; closeSession: () => Promise; + detachSession: (sessionId: string, clientId?: string) => Promise; setSessionApprovalMode: () => Promise<{ mode: string }>; workspaceMcp: () => Promise; workspaceMcpTools: () => Promise; @@ -162,6 +170,7 @@ const sdkMocks = vi.hoisted(() => { const workspaceProviders = vi.fn(); const listWorkspaceSessions = vi.fn(); const closeSession = vi.fn(); + const detachSession = vi.fn(); const setSessionApprovalMode = vi.fn(); const workspaceMcp = vi.fn(); const workspaceMcpTools = vi.fn(); @@ -196,6 +205,7 @@ const sdkMocks = vi.hoisted(() => { workspaceProviders = workspaceProviders; listWorkspaceSessions = listWorkspaceSessions; closeSession = closeSession; + detachSession = detachSession; setSessionApprovalMode = setSessionApprovalMode; workspaceMcp = workspaceMcp; workspaceMcpTools = workspaceMcpTools; @@ -255,6 +265,7 @@ const sdkMocks = vi.hoisted(() => { workspaceByCwd, MockDaemonClient, MockDaemonSessionClient, + detachSession, workspaceMcpTools, getPendingPrompts, removePendingPrompt, @@ -279,6 +290,8 @@ const sdkMocks = vi.hoisted(() => { listWorkspaceSessions.mockResolvedValue([]); closeSession.mockReset(); closeSession.mockResolvedValue(undefined); + detachSession.mockReset(); + detachSession.mockResolvedValue(undefined); setSessionApprovalMode.mockReset(); setSessionApprovalMode.mockResolvedValue({ mode: 'default' }); workspaceMcp.mockReset(); @@ -401,17 +414,20 @@ describe('DaemonSessionProvider', () => { sdkMocks.reset(); }); - afterEach(() => { + afterEach(async () => { + vi.useRealTimers(); if (root) { - act(() => { + await act(async () => { root?.unmount(); }); + await flushPromises(); root = null; } if (container) { container.remove(); container = null; } + clearSidechannelFollowupSuggestion(); vi.unstubAllGlobals(); }); @@ -6518,6 +6534,105 @@ describe('DaemonSessionProvider', () => { } }); + it('clears a source passive-assistant timer before transactional commit', async () => { + vi.useFakeTimers(); + try { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + const source = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + events: async function* passiveSourceEvents( + opts: { signal?: AbortSignal } = {}, + ) { + yield { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'source partial' }, + }, + }, + }; + await new Promise((resolve) => + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + }, + }); + const target = createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + hasActivePrompt: true, + replaySnapshot: { + compactedReplay: [ + { + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'target partial' }, + }, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(source, target); + let actions: DaemonSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + let streamingState: ReturnType = 'idle'; + + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + streamingState = useDaemonStreamingState(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + await flushPromises(); + }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'source partial', streaming: true }, + ]); + + await act(async () => { + await requireActions(actions).loadSession('session-b'); + await flushPromises(); + }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'target partial', streaming: true }, + ]); + expect(streamingState).toBe('responding'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000); + await flushPromises(); + }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'target partial', streaming: true }, + ]); + expect(streamingState).toBe('responding'); + } finally { + vi.useRealTimers(); + } + }); + it('finishes replayed assistant streaming when replay completes', async () => { vi.useFakeTimers(); try { @@ -7263,6 +7378,10 @@ describe('DaemonSessionProvider', () => { }); it('retries a session switch while the target session is closing', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); const firstSession = createMockSession({ sessionId: 'session-a' }); const secondSession = createMockSession({ sessionId: 'session-b' }); sdkMocks.sessions.push(firstSession); @@ -7313,6 +7432,14 @@ describe('DaemonSessionProvider', () => { await flushPromises(); }); expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(1); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-a', + sessionTransition: { + phase: 'preparing', + targetSessionId: 'session-b', + }, + }); await act(async () => { await vi.advanceTimersByTimeAsync(10); @@ -7342,6 +7469,85 @@ describe('DaemonSessionProvider', () => { } }); + it('retains legacy closing-session retries without client identity', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: [], + }); + sdkMocks.sessions.push(createMockSession({ sessionId: 'session-a' })); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + reconnectDelayMs: 10, + maxReconnectDelayMs: 100, + }); + await act(async () => { + await flushPromises(); + }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + const closingError = new DaemonHttpError( + 404, + { + error: + 'No session with id "session-b". The session is closing; retry after close completes', + sessionId: 'session-b', + }, + 'POST /session/:id/load: No session with id "session-b". The session is closing; retry after close completes', + ); + sdkMocks.MockDaemonSessionClient.load + .mockRejectedValueOnce(closingError) + .mockRejectedValueOnce(closingError); + sdkMocks.sessions.push(createMockSession({ sessionId: 'session-b' })); + + const random = vi.spyOn(Math, 'random').mockReturnValue(0.5); + vi.useFakeTimers(); + try { + let switched: Promise | undefined; + act(() => { + switched = requireActions(actions).loadSession('session-b'); + }); + if (!switched) throw new Error('Session switch was not started'); + await act(async () => { + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(3); + + await act(async () => { + await expect(switched).resolves.toBeUndefined(); + await flushPromises(); + }); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-b', + missingSession: false, + }); + } finally { + random.mockRestore(); + vi.useRealTimers(); + } + }); + it('does not retry a closing session when auto-reconnect is disabled', async () => { sdkMocks.sessions.push(createMockSession({ sessionId: 'session-a' })); let actions: DaemonSessionActions | undefined; @@ -7379,6 +7585,10 @@ describe('DaemonSessionProvider', () => { }); it('does not retry a closing session after a newer switch', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); sdkMocks.sessions.push(createMockSession({ sessionId: 'session-a' })); let actions: DaemonSessionActions | undefined; @@ -7412,13 +7622,92 @@ describe('DaemonSessionProvider', () => { vi.useFakeTimers(); try { - const loadB = requireActions(actions) - .loadSession('session-b') - .catch(() => undefined); + let loadB: Promise | undefined; + act(() => { + loadB = requireActions(actions) + .loadSession('session-b') + .catch(() => undefined); + }); + if (!loadB) throw new Error('Session switch was not started'); + await act(async () => { + await flushPromises(); + }); + let loadC: Promise | undefined; + act(() => { + loadC = requireActions(actions).loadSession('session-c'); + }); + if (!loadC) throw new Error('Newer session switch was not started'); + await act(async () => { + await flushPromises(); + }); + await expect(loadC).resolves.toBeUndefined(); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + await flushPromises(); + }); + await loadB; + + expect( + sdkMocks.MockDaemonSessionClient.load.mock.calls.map((call) => call[1]), + ).toEqual(['session-b', 'session-c']); + } finally { + vi.useRealTimers(); + } + }); + + it('stops legacy closing-session retries after a newer switch', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: [], + }); + sdkMocks.sessions.push(createMockSession({ sessionId: 'session-a' })); + let actions: DaemonSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + reconnectDelayMs: 50, + maxReconnectDelayMs: 50, + }); + await act(async () => { + await flushPromises(); + }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce( + new DaemonHttpError( + 404, + { + error: + 'No session with id "session-b". The session is closing; retry after close completes', + sessionId: 'session-b', + }, + 'POST /session/:id/load: No session with id "session-b". The session is closing; retry after close completes', + ), + ); + sdkMocks.sessions.push(createMockSession({ sessionId: 'session-c' })); + + vi.useFakeTimers(); + try { + let loadB: Promise | undefined; + act(() => { + loadB = requireActions(actions) + .loadSession('session-b') + .catch(() => undefined); + }); + if (!loadB) throw new Error('Session switch was not started'); await act(async () => { await flushPromises(); }); - const loadC = requireActions(actions).loadSession('session-c'); + let loadC: Promise | undefined; + act(() => { + loadC = requireActions(actions).loadSession('session-c'); + }); + if (!loadC) throw new Error('Newer session switch was not started'); await act(async () => { await flushPromises(); }); @@ -7486,26 +7775,101 @@ describe('DaemonSessionProvider', () => { expect(loadCalls[0]?.[3]).toBe('client-b'); }); - it('exposes daemon capabilities on the connection state', async () => { + it('rejects a concurrent branch and opens the first branch', async () => { sdkMocks.capabilities.mockResolvedValue({ v: 1, mode: 'http-bridge', - features: ['client_heartbeat', 'workspace_memory'], - modelServices: ['qwen'], workspaceCwd: '/mock-workspace', + features: ['client_identity'], + modelServices: [], }); - sdkMocks.sessions.push(createMockSession()); + const sourceSession = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + }); + const branchedSession = createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + }); + const firstBranch = createDeferred<{ + sessionId: string; + displayName: string; + clientId: string; + }>(); + sdkMocks.branchSession.mockReturnValueOnce(firstBranch.promise); + sdkMocks.sessions.push(sourceSession, branchedSession); + let actions: DaemonSessionActions | undefined; let connection: DaemonConnectionState | undefined; function Harness() { + actions = useDaemonActions(); connection = useDaemonConnection(); return null; } await renderWithProvider(, { autoConnect: true }); - - expect(connection?.capabilities).toMatchObject({ - features: ['client_heartbeat', 'workspace_memory'], + await act(async () => { + await flushPromises(); + }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + + let first!: Promise<{ sessionId: string; displayName: string }>; + let second!: Promise; + await act(async () => { + first = requireActions(actions).branchSession('First'); + second = requireActions(actions) + .branchSession('Second') + .catch((error: unknown) => error); + await flushPromises(); + }); + await expect(second).resolves.toMatchObject({ name: 'InvalidStateError' }); + expect(sdkMocks.branchSession).toHaveBeenCalledOnce(); + let firstResult: { sessionId: string; displayName: string } | undefined; + await act(async () => { + firstBranch.resolve({ + sessionId: 'session-b', + displayName: 'First', + clientId: 'client-b', + }); + firstResult = await first; + await flushPromises(); + }); + expect(firstResult).toEqual({ + sessionId: 'session-b', + displayName: 'First', + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + expect(sdkMocks.MockDaemonSessionClient.load.mock.calls[0]?.[1]).toBe( + 'session-b', + ); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-b', + clientId: 'client-b', + sessionTransition: undefined, + }); + }); + + it('exposes daemon capabilities on the connection state', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat', 'workspace_memory'], + modelServices: ['qwen'], + workspaceCwd: '/mock-workspace', + }); + sdkMocks.sessions.push(createMockSession()); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + + expect(connection?.capabilities).toMatchObject({ + features: ['client_heartbeat', 'workspace_memory'], workspaceCwd: '/mock-workspace', }); }); @@ -8426,210 +8790,1838 @@ describe('DaemonSessionProvider', () => { ]); }); - it('loads controlled sessionId changes', async () => { - const nextSession = createDeferred(); - sdkMocks.sessions.push( - createMockSession({ - sessionId: 'session-a', - replaySnapshot: createTextReplaySnapshot('old transcript'), - }), + it('preserves the current session while a transactional target fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + const sourceEvent = createDeferred(); + const sourceStarted = createDeferred(); + const currentSession = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + replaySnapshot: createTextReplaySnapshot('old transcript'), + events: async function* sourceEvents( + opts: { signal?: AbortSignal } = {}, + ) { + sourceStarted.resolve(); + const event = await sourceEvent.promise; + if (opts.signal?.aborted) return; + yield event; + await new Promise((resolve) => + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + }, + }); + sdkMocks.sessions.push(currentSession); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; let blocks: readonly DaemonTranscriptBlock[] = []; let connection: DaemonConnectionState | undefined; function Harness() { + actions = useDaemonActions(); blocks = useDaemonTranscriptBlocks(); connection = useDaemonConnection(); return null; } - await renderWithProvider(, { - autoConnect: true, + await renderWithProvider(, { autoConnect: true }); + await sourceStarted.promise; + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + let load!: Promise; + act(() => { + load = requireActions(actions).loadSession('session-b'); + }); + const outcome = load.catch((error: unknown) => error); + await act(async () => flushPromises()); + + expect(connection).toMatchObject({ + status: 'connected', sessionId: 'session-a', + clientId: 'client-a', + sessionTransition: { + phase: 'preparing', + targetSessionId: 'session-b', + targetClientId: expect.any(String), + }, }); - expect(connection).toMatchObject({ sessionId: 'session-a' }); expect(blocks).toMatchObject([ { kind: 'assistant', text: 'old transcript' }, ]); - sdkMocks.MockDaemonSessionClient.load.mockClear(); - sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( - async () => nextSession.promise, - ); - - act(() => { - root?.render( - - - , - ); - }); + expect(currentSession.detach).not.toHaveBeenCalled(); await act(async () => { - await flushPromises(); + await expect(requireActions(actions).cancel()).resolves.toBeUndefined(); + await expect( + requireActions(actions).sendPrompt('must not target A'), + ).rejects.toThrow('A session switch is still preparing'); }); + expect(currentSession.cancel).toHaveBeenCalledOnce(); + expect(currentSession.prompt).not.toHaveBeenCalled(); - expect(connection).toMatchObject({ - sessionId: 'session-b', - loadingTranscript: true, + sourceEvent.resolve({ + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: ' still live' }, + }, + }, }); - expect(blocks).toEqual([]); - nextSession.resolve( - createMockSession({ - sessionId: 'session-b', - replaySnapshot: createTextReplaySnapshot('new transcript'), - }), - ); await act(async () => { - await wait(5); await flushPromises(); + await flushTranscriptDispatch(); }); + expect( + blocks.map((block) => ('text' in block ? block.text : undefined)), + ).toEqual(['old transcript', ' still live']); - expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledWith( - expect.anything(), - 'session-b', - { workspaceCwd: '/mock-workspace', timeoutMs: 70_000 }, - expect.any(String), - ); - expect(connection).toMatchObject({ sessionId: 'session-b' }); - expect(blocks).toMatchObject([ - { kind: 'assistant', text: 'new transcript' }, - ]); - }); - - it('clears transcript loading after replay before metadata finishes', async () => { - const providers = createDeferred(); - const commands = - createDeferred>>(); - const context = - createDeferred>>(); - sdkMocks.workspaceProviders.mockReturnValueOnce(providers.promise); - sdkMocks.sessions.push( - createMockSession({ - sessionId: 'session-a', - replaySnapshot: createTextReplaySnapshot('restored transcript'), - supportedCommands: vi.fn(() => commands.promise), - context: vi.fn(() => context.promise), - }), - ); - let blocks: readonly DaemonTranscriptBlock[] = []; - let connection: DaemonConnectionState | undefined; - - function Harness() { - blocks = useDaemonTranscriptBlocks(); - connection = useDaemonConnection(); - return null; - } - - await renderWithProvider(, { - autoConnect: true, - sessionId: 'session-a', - }); await act(async () => { + target.reject(new Error('target load failed')); await flushPromises(); }); - - expect(blocks).toMatchObject([ - { kind: 'assistant', text: 'restored transcript' }, - ]); + await expect(outcome).resolves.toMatchObject({ + message: 'target load failed', + }); expect(connection).toMatchObject({ status: 'connected', sessionId: 'session-a', - loadingTranscript: undefined, + clientId: 'client-a', + sessionTransition: { + phase: 'failed', + targetClientId: expect.any(String), + }, }); + expect( + blocks.map((block) => ('text' in block ? block.text : undefined)), + ).toEqual(['old transcript', ' still live']); + }); - providers.resolve({ - v: 1, + it('does not adopt a target restored under stale provider configuration', async () => { + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', - initialized: true, - providers: [], + features: ['client_identity'], }); - commands.resolve({ - v: 1, + const source = createMockSession({ sessionId: 'session-a', - availableCommands: [], - availableSkills: [], + clientId: 'client-a', }); - context.resolve({ - v: 1, + const reloadedSource = createMockSession({ sessionId: 'session-a', - workspaceCwd: '/mock-workspace', - state: {}, + clientId: 'client-a-reloaded', }); - await act(async () => { - await flushPromises(); - }); - }); - - it('ignores stale metadata from a replaced same-id attachment', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(null, { status: 204 })), - ); - const providersA = createDeferred(); - const commandsA = - createDeferred>>(); - const contextA = - createDeferred>>(); - sdkMocks.workspaceProviders.mockReturnValueOnce(providersA.promise); - sdkMocks.sessions.push( - createMockSession({ - sessionId: 'session-a', - clientId: 'client-a', - replaySnapshot: createTextReplaySnapshot('session a transcript'), - supportedCommands: vi.fn(() => commandsA.promise), - context: vi.fn(() => contextA.promise), - }), - createMockSession({ - sessionId: 'session-a', - clientId: 'client-b', - replaySnapshot: createTextReplaySnapshot('replacement transcript'), - }), - ); - let blocks: readonly DaemonTranscriptBlock[] = []; + const target = createDeferred(); + sdkMocks.sessions.push(source); + let actions: DaemonSessionActions | undefined; let connection: DaemonConnectionState | undefined; function Harness() { - blocks = useDaemonTranscriptBlocks(); + actions = useDaemonActions(); connection = useDaemonConnection(); return null; } await renderWithProvider(, { autoConnect: true, + maxBlocks: 1_024, sessionId: 'session-a', - clientId: 'client-a', }); - await act(async () => { - await flushPromises(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + let transition!: Promise; + act(() => { + transition = requireActions(actions).loadSession('session-b'); }); - expect(connection).toMatchObject({ sessionId: 'session-a' }); - expect(blocks).toMatchObject([ - { kind: 'assistant', text: 'session a transcript' }, - ]); + const outcome = transition.catch((error: unknown) => error); + await act(async () => flushPromises()); - act(() => { + sdkMocks.sessions.push(reloadedSource); + await act(async () => { root?.render( , ); - }); - await act(async () => { await flushPromises(); }); - expect(connection).toMatchObject({ - sessionId: 'session-a', - clientId: 'client-b', - loadingTranscript: undefined, - }); + await expect(outcome).resolves.toMatchObject({ name: 'AbortError' }); + + const freshTarget = createMockSession({ + sessionId: 'session-b', + clientId: 'client-b-fresh', + }); + sdkMocks.sessions.push(freshTarget); + let retry!: Promise; + act(() => { + retry = requireActions(actions).loadSession('session-b'); + }); + await act(async () => { + target.resolve( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b-stale', + }), + ); + await retry; + await flushPromises(); + }); + expect(connection).toMatchObject({ + sessionId: 'session-b', + clientId: 'client-b-fresh', + }); + expect(detachFetch).toHaveBeenCalledWith( + expect.stringContaining('/session/session-b/detach'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Qwen-Client-Id': 'client-b-stale', + }), + }), + ); + expect( + detachFetch.mock.calls.filter(([input]) => + String(input).includes('/session/session-b/detach'), + ), + ).toHaveLength(1); + }); + + it('does not commit a ready target after a real unmount', async () => { + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + () => target.promise, + ); + let transition!: Promise; + act(() => { + transition = requireActions(actions).loadSession('session-b'); + }); + const outcome = transition.catch((error: unknown) => error); + await act(async () => flushPromises()); + + act(() => { + target.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + root?.unmount(); + root = null; + }); + await act(async () => flushPromises()); + + await expect(outcome).resolves.toMatchObject({ name: 'AbortError' }); + expect(detachFetch).toHaveBeenCalledWith( + expect.stringContaining('/session/session-b/detach'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Qwen-Client-Id': 'client-b', + }), + }), + ); + expect( + detachFetch.mock.calls.filter(([input]) => + String(input).includes('/session/session-b/detach'), + ), + ).toHaveLength(1); + }); + + it('retires a committed target when unmounted before its runner starts', async () => { + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + () => target.promise, + ); + let transition!: Promise; + act(() => { + transition = requireActions(actions).loadSession('session-b'); + }); + await act(async () => flushPromises()); + + await act(async () => { + target.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await transition; + root?.unmount(); + root = null; + }); + await flushPromises(); + + expect(detachFetch).toHaveBeenCalledWith( + expect.stringContaining('/session/session-b/detach'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Qwen-Client-Id': 'client-b', + }), + }), + ); + expect( + detachFetch.mock.calls.filter(([input]) => + String(input).includes('/session/session-b/detach'), + ), + ).toHaveLength(1); + }); + + it('does not duplicate the initial controlled load when workspace is set', async () => { + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + + await renderWithProvider(null, { + autoConnect: true, + sessionId: 'session-a', + workspaceCwd: '/mock-workspace', + }); + + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + }); + + it('unblocks source actions after timeout while the raw restore settles', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + const source = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + }); + sdkMocks.sessions.push(source); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + vi.useFakeTimers(); + let outcome!: Promise; + act(() => { + outcome = requireActions(actions) + .loadSession('session-b') + .catch((error: unknown) => error); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(75_000); + }); + + await act(async () => { + await expect(outcome).resolves.toMatchObject({ + message: 'Session transition timed out', + }); + }); + await act(async () => { + await expect( + requireActions(actions).setModel('source-model'), + ).resolves.toEqual({ modelId: 'source-model' }); + }); + expect(connection).toMatchObject({ + sessionId: 'session-a', + sessionTransition: { phase: 'failed' }, + }); + + const retriedTarget = createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + }); + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce(retriedTarget); + let retry!: Promise; + act(() => { + retry = requireActions(actions).loadSession('session-b'); + }); + await act(async () => { + target.reject(new Error('old request expired')); + await flushPromises(); + await retry; + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(2); + expect(connection?.sessionId).toBe('session-b'); + }); + + it('adopts a late raw result when the same target is requested again', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + vi.useFakeTimers(); + let first!: Promise; + act(() => { + first = requireActions(actions) + .loadSession('session-b') + .catch((error: unknown) => error); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(75_000); + }); + await expect(first).resolves.toMatchObject({ + message: 'Session transition timed out', + }); + + let retry!: Promise; + act(() => { + retry = requireActions(actions).loadSession('session-b'); + }); + await act(async () => { + target.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await retry; + await flushPromises(); + }); + + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-b', + clientId: 'client-b', + }); + }); + + it('commits a transactional target before resolving its public promise', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity', 'session_transcript_pagination'], + }); + let sourceEventSignal: AbortSignal | undefined; + const currentSession = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + replaySnapshot: createTextReplaySnapshot('old transcript'), + events: async function* sourceEvents( + opts: { signal?: AbortSignal } = {}, + ) { + sourceEventSignal = opts.signal; + await new Promise((resolve) => + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + if (!opts.signal?.aborted) { + yield { id: 3, v: 1, type: 'debug', data: {} }; + } + }, + }); + sdkMocks.sessions.push(currentSession); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + let connection: DaemonConnectionState | undefined; + let transcriptStore: DaemonTranscriptStore | undefined; + let history: ReturnType | undefined; + let ownerGuard: ReturnType | undefined; + let committedSessionId: string | undefined; + + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + connection = useDaemonConnection(); + transcriptStore = useDaemonTranscriptStore(); + history = useDaemonTranscriptHistory(); + ownerGuard = useDaemonSessionOwnerGuard(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + onSessionTransitionCommit: (target) => { + committedSessionId = target.sessionId; + throw new Error('observer failed'); + }, + }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + const sourceOwner = ownerGuard?.capture(); + let visibleAtResolve: { + sessionId?: string; + text?: string; + sourceIsCurrent?: boolean; + targetIsCurrent?: boolean; + } = {}; + let load!: Promise; + act(() => { + load = requireActions(actions) + .loadSession('session-b', { workspaceCwd: '/target-workspace' }) + .then(() => { + const snapshot = transcriptStore?.getSnapshot(); + visibleAtResolve = { + sessionId: committedSessionId, + text: + snapshot?.blocks[0]?.kind === 'assistant' + ? snapshot.blocks[0].text + : undefined, + sourceIsCurrent: sourceOwner?.isCurrent(), + targetIsCurrent: ownerGuard?.capture().isCurrent(), + }; + }); + }); + await act(async () => { + target.resolve( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + workspaceCwd: '/target-workspace', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + { + v: 1, + type: 'history_truncated', + data: { + reason: 'replay_window_exceeded', + fullTranscriptAvailable: true, + recordId: 'record-b', + }, + }, + ...createTextReplaySnapshot('new transcript').compactedReplay, + ], + liveJournal: [], + }, + }), + ); + await load; + await flushPromises(); + }); + + expect(visibleAtResolve).toEqual({ + sessionId: 'session-b', + text: 'new transcript', + sourceIsCurrent: false, + targetIsCurrent: true, + }); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-b', + clientId: 'client-b', + workspaceCwd: '/target-workspace', + sessionTransition: undefined, + }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'new transcript' }, + ]); + expect(history?.hasMore).toBe(true); + expect(sourceEventSignal?.aborted).toBe(true); + expect(detachFetch).toHaveBeenCalledOnce(); + const [detachInput, detachInit] = detachFetch.mock.calls[0] ?? []; + expect(new URL(String(detachInput)).pathname).toContain( + '/session/session-a/detach', + ); + expect(new Headers(detachInit?.headers).get('X-Qwen-Client-Id')).toBe( + 'client-a', + ); + }); + + it('retires a stale candidate through its original endpoint', async () => { + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + function Harness() { + actions = useDaemonActions(); + return null; + } + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + let outcome!: Promise; + act(() => { + outcome = requireActions(actions) + .loadSession('session-b') + .catch((error: unknown) => error); + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a-2' }), + ); + + await act(async () => { + root?.render( + + + , + ); + await flushPromises(); + }); + await act(async () => { + target.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await flushPromises(); + }); + + await expect(outcome).resolves.toMatchObject({ name: 'AbortError' }); + const candidateDetach = detachFetch.mock.calls.find( + ([, init]) => + new Headers(init?.headers).get('X-Qwen-Client-Id') === 'client-b', + ); + expect(candidateDetach).toBeDefined(); + expect(new URL(String(candidateDetach?.[0])).origin).toBe( + 'http://127.0.0.1:4170', + ); + expect( + new Headers(candidateDetach?.[1]?.headers).get('X-Qwen-Client-Id'), + ).toBe('client-b'); + }); + + it('stages a large target replay in bounded reducer batches', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const sdk = await import('@qwen-code/sdk/daemon'); + const createStore = sdk.createDaemonTranscriptStore; + const batchSizes: number[] = []; + const createStoreSpy = vi + .spyOn(sdk, 'createDaemonTranscriptStore') + .mockImplementation((seed) => { + const next = createStore(seed); + const dispatch = next.dispatch.bind(next); + next.dispatch = (events) => { + batchSizes.push(Array.isArray(events) ? events.length : 1); + return dispatch(events); + }; + return next; + }); + let actions: DaemonSessionActions | undefined; + function Harness() { + actions = useDaemonActions(); + return null; + } + await renderWithProvider(, { autoConnect: true }); + batchSizes.length = 0; + const compactedReplay = Object.freeze( + Array.from( + { length: 513 }, + (_, index): DaemonEvent => ({ + id: index + 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `${index} ` }, + }, + }, + }), + ), + ); + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + replaySnapshot: { + compactedReplay: compactedReplay as DaemonEvent[], + liveJournal: [], + }, + }), + ); + + await act(async () => { + await requireActions(actions).loadSession('session-b'); + await flushPromises(); + }); + + expect(batchSizes).toEqual([512, 1]); + createStoreSpy.mockRestore(); + }); + + it('preserves a transactional replay that exceeds the configured block cap', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity', 'session_transcript_pagination'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + let actions: DaemonSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + await renderWithProvider(, { autoConnect: true, maxBlocks: 1 }); + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + replaySnapshot: { + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'target prompt' }, + }, + }, + }, + { + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'target answer' }, + }, + }, + }, + ], + liveJournal: [], + }, + }), + ); + + await act(async () => { + await requireActions(actions).loadSession('session-b'); + await flushPromises(); + }); + + expect(blocks).toMatchObject([ + { kind: 'user', text: 'target prompt' }, + { kind: 'assistant', text: 'target answer' }, + ]); + }); + + it('replaces the source follow-up with the last staged target suggestion', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/work/a', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + workspaceCwd: '/work/a', + }), + ); + let actions: DaemonSessionActions | undefined; + let ownerGuard: ReturnType | undefined; + function Harness() { + actions = useDaemonActions(); + ownerGuard = useDaemonSessionOwnerGuard(); + return null; + } + await renderWithProvider(, { autoConnect: true }); + const sourceOwner = ownerGuard?.capture(); + const sourceCurrentWhenPublished: boolean[] = []; + const unsubscribe = subscribeSidechannelFollowupSuggestion(() => { + sourceCurrentWhenPublished.push(sourceOwner?.isCurrent() ?? true); + }); + publishSidechannelFollowupSuggestion({ + sessionId: 'session-a', + promptId: 'prompt-a', + suggestion: 'stale suggestion', + }); + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-b', + workspaceCwd: '/work/b', + replaySnapshot: { + compactedReplay: [ + { + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 'session-a', + promptId: 'prompt-b1', + suggestion: 'older target suggestion', + }, + }, + { + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 'session-a', + promptId: 'prompt-b2', + suggestion: 'latest target suggestion', + }, + }, + ], + liveJournal: [], + }, + }), + ); + + await act(async () => { + await requireActions(actions).loadSession('session-a', { + workspaceCwd: '/work/b', + }); + await flushPromises(); + }); + + expect(getSidechannelFollowupSuggestion()).toEqual({ + sessionId: 'session-a', + promptId: 'prompt-b2', + suggestion: 'latest target suggestion', + }); + expect(sourceCurrentWhenPublished.slice(1)).toEqual([false, false]); + unsubscribe(); + clearSidechannelFollowupSuggestion(); + }); + + it('keeps a transition started synchronously by the commit observer', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + const sourceStarted = createDeferred(); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + events: async function* sourceEvents( + options: { signal?: AbortSignal } = {}, + ) { + sourceStarted.resolve(); + yield* []; + await new Promise((resolve) => + options.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + }, + }), + ); + const targetB = createDeferred(); + const targetC = createMockSession({ + sessionId: 'session-c', + clientId: 'client-c', + }); + let actions: DaemonSessionActions | undefined; + let nestedLoad: Promise | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + onSessionTransitionCommit: (target) => { + if (target.sessionId === 'session-b') { + nestedLoad = requireActions(actions).loadSession('session-c'); + } + }, + }); + await sourceStarted.promise; + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load + .mockImplementationOnce(async () => targetB.promise) + .mockResolvedValueOnce(targetC); + let firstLoad!: Promise; + act(() => { + firstLoad = requireActions(actions).loadSession('session-b'); + }); + await act(async () => { + targetB.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await firstLoad; + await nestedLoad; + await flushPromises(); + }); + + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(2); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-c', + clientId: 'client-c', + }); + }); + + it('keeps a notice dismissed by a later staged replay event', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + let notices: readonly DaemonSessionNotice[] = []; + + function Harness() { + actions = useDaemonActions(); + notices = useDaemonSessionNotices().notices; + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + let load!: Promise; + await act(async () => { + load = requireActions(actions).loadSession('session-b'); + await flushPromises(); + }); + + await act(async () => { + target.resolve( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + replaySnapshot: { + compactedReplay: [], + liveJournal: [ + { + id: 1, + v: 1, + type: 'session_recording_degraded', + data: { sessionId: 'session-b', reason: 'write_failed' }, + }, + { + id: 2, + v: 1, + type: 'session_snapshot', + data: { sessionId: 'session-b', recordingDegraded: false }, + }, + ], + }, + }), + ); + await load; + await flushPromises(); + }); + + expect(notices).toEqual([]); + }); + + it('restores a staged notice that reappears after dismissal', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + let actions: DaemonSessionActions | undefined; + let notices: readonly DaemonSessionNotice[] = []; + + function Harness() { + actions = useDaemonActions(); + notices = useDaemonSessionNotices().notices; + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + replaySnapshot: { + compactedReplay: [], + liveJournal: [ + { + id: 1, + v: 1, + type: 'session_recording_degraded', + data: { sessionId: 'session-b', reason: 'write_failed' }, + }, + { + id: 2, + v: 1, + type: 'session_snapshot', + data: { sessionId: 'session-b', recordingDegraded: false }, + }, + { + id: 3, + v: 1, + type: 'session_snapshot', + data: { sessionId: 'session-b', recordingDegraded: true }, + }, + ], + }, + }), + ); + + await act(async () => { + await requireActions(actions).loadSession('session-b'); + await flushPromises(); + }); + + expect(notices).toHaveLength(1); + expect(notices[0]?.id).toBe('daemon.session_recording_degraded:session-b'); + }); + + it('replays a later snapshot reload after a prepared target commits', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const requestResync = createDeferred(); + const target = createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + replaySnapshot: createTextReplaySnapshot('target transcript'), + events: async function* resyncEvents() { + await requestResync.promise; + yield { + id: 3, + v: 1, + type: 'state_resync_required', + data: { reason: 'ring_evicted' }, + }; + }, + }); + let actions: DaemonSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target, + ); + await act(async () => { + await requireActions(actions).loadSession('session-b'); + await flushPromises(); + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + replaySnapshot: createTextReplaySnapshot('reloaded transcript'), + }), + ); + await act(async () => { + requestResync.resolve(); + await flushPromises(); + }); + await act(async () => { + await vi.waitFor(() => + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'reloaded transcript' }, + ]), + ); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(3); + expect(sdkMocks.sessions).toHaveLength(0); + }); + + it('carries a target live-journal repair episode into its runner', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const releaseTerminal = createDeferred(); + const marker: DaemonEvent = { + id: 2, + v: 1, + type: 'history_truncated', + promptId: 'prompt-b', + data: { + reason: 'replay_window_exceeded', + scope: 'live_journal', + truncatedEvents: 4, + retainedEvents: 2, + maxBytes: 1024, + maxEvents: 2, + fullTranscriptAvailable: true, + }, + }; + const target = createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + lastEventId: 3, + replaySnapshot: { + compactedReplay: [], + liveJournal: [ + marker, + { + id: 3, + v: 1, + type: 'session_update', + promptId: 'prompt-b', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial' }, + }, + }, + }, + ], + }, + events: async function* terminalEvents() { + await releaseTerminal.promise; + yield { + id: 4, + v: 1, + type: 'turn_complete', + promptId: 'prompt-b', + data: { promptId: 'prompt-b', stopReason: 'end_turn' }, + }; + }, + }); + let actions: DaemonSessionActions | undefined; + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target, + ); + await act(async () => { + await requireActions(actions).loadSession('session-b'); + await flushPromises(); + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-b', + clientId: 'client-b', + replaySnapshot: { + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + promptId: 'prompt-b', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'prompt' }, + }, + }, + }, + { + id: 4, + v: 1, + type: 'turn_complete', + promptId: 'prompt-b', + data: { promptId: 'prompt-b', stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + }), + ); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + await act(async () => { + releaseTerminal.resolve(); + await flushPromises(); + }); + await vi.waitFor(() => + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(), + ); + }); + + it('coalesces load and resume for the same transactional target', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const target = createDeferred(); + let actions: DaemonSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockImplementation( + async () => target.promise, + ); + let first!: Promise; + let second!: Promise; + act(() => { + first = requireActions(actions).loadSession('session-b'); + second = requireActions(actions).resumeSession('session-b'); + }); + await act(async () => flushPromises()); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + + await act(async () => { + target.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await Promise.all([first, second]); + await flushPromises(); + }); + }); + + it('rejects a malformed modern target without replacing the source', async () => { + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce(async () => + createMockSession({ sessionId: 'session-b', clientId: '' }), + ); + await act(async () => { + await expect( + requireActions(actions).loadSession('session-b'), + ).rejects.toThrow('invalid owner identity'); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + sessionId: 'session-a', + clientId: 'client-a', + sessionTransition: { phase: 'failed' }, + }); + expect(detachFetch).toHaveBeenCalledOnce(); + }); + + it('fails closed when a modern source has no client identity', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: '' }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + await act(async () => { + await expect( + requireActions(actions).loadSession('session-b'), + ).rejects.toThrow('current session has no clientId'); + await flushPromises(); + }); + + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-a', + sessionTransition: { phase: 'failed' }, + }); + }); + + it('runs only the latest queued transactional restore', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const targetB = createDeferred(); + const targetD = createDeferred(); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + let activeRestores = 0; + let maxActiveRestores = 0; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockImplementation( + async (_client, sessionId) => { + activeRestores += 1; + maxActiveRestores = Math.max(maxActiveRestores, activeRestores); + try { + if (sessionId === 'session-b') return await targetB.promise; + if (sessionId === 'session-d') return await targetD.promise; + throw new Error(`Unexpected restore for ${sessionId}`); + } finally { + activeRestores -= 1; + } + }, + ); + let loadB!: Promise; + let loadC!: Promise; + let loadD!: Promise; + act(() => { + loadB = requireActions(actions) + .loadSession('session-b') + .catch((error: unknown) => error); + loadC = requireActions(actions) + .loadSession('session-c') + .catch((error: unknown) => error); + loadD = requireActions(actions).loadSession('session-d'); + }); + await act(async () => flushPromises()); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + + await act(async () => { + targetB.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(2); + expect(sdkMocks.MockDaemonSessionClient.load.mock.calls[1]?.[1]).toBe( + 'session-d', + ); + + await act(async () => { + targetD.resolve( + createMockSession({ sessionId: 'session-d', clientId: 'client-d' }), + ); + await loadD; + await flushPromises(); + }); + await expect(loadB).resolves.toMatchObject({ name: 'AbortError' }); + await expect(loadC).resolves.toMatchObject({ name: 'AbortError' }); + expect(maxActiveRestores).toBe(1); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-d', + clientId: 'client-d', + }); + }); + + it('preserves A when a controlled transactional target fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + replaySnapshot: createTextReplaySnapshot('old transcript'), + }), + ); + const target = createDeferred(); + let blocks: readonly DaemonTranscriptBlock[] = []; + let connection: DaemonConnectionState | undefined; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-a', + clientId: 'client-a', + sessionTransition: { phase: 'preparing' }, + }); + + await act(async () => { + target.reject(new Error('controlled target failed')); + await flushPromises(); + }); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-a', + clientId: 'client-a', + sessionTransition: { phase: 'failed' }, + }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'old transcript' }, + ]); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + + await act(async () => { + root?.render( + + + , + ); + await flushPromises(); + }); + expect(connection?.sessionTransition).toBeUndefined(); + }); + + it('cancels a pending controlled target when props return to A', async () => { + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + sdkMocks.sessions.push( + createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), + ); + const target = createDeferred(); + let connection: DaemonConnectionState | undefined; + function Harness() { + connection = useDaemonConnection(); + return null; + } + const renderControlled = (sessionId: string) => + root?.render( + + + , + ); + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); + await act(async () => { + renderControlled('session-b'); + await flushPromises(); + }); + expect(connection?.sessionTransition?.targetSessionId).toBe('session-b'); + + await act(async () => { + renderControlled('session-a'); + await flushPromises(); + }); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-a', + clientId: 'client-a', + }); + expect(connection?.sessionTransition).toBeUndefined(); + + await act(async () => { + target.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await flushPromises(); + }); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-a', + clientId: 'client-a', + }); + expect(detachFetch).toHaveBeenCalledOnce(); + }); + + it('loads controlled sessionId changes', async () => { + const nextSession = createDeferred(); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + replaySnapshot: createTextReplaySnapshot('old transcript'), + }), + ); + let blocks: readonly DaemonTranscriptBlock[] = []; + let connection: DaemonConnectionState | undefined; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + }); + expect(connection).toMatchObject({ sessionId: 'session-a' }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'old transcript' }, + ]); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => nextSession.promise, + ); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + + expect(connection).toMatchObject({ + sessionId: 'session-b', + loadingTranscript: true, + }); + expect(blocks).toEqual([]); + nextSession.resolve( + createMockSession({ + sessionId: 'session-b', + replaySnapshot: createTextReplaySnapshot('new transcript'), + }), + ); + await act(async () => { + await wait(5); + await flushPromises(); + }); + + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledWith( + expect.anything(), + 'session-b', + { workspaceCwd: '/mock-workspace', timeoutMs: 70_000 }, + expect.any(String), + ); + expect(connection).toMatchObject({ sessionId: 'session-b' }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'new transcript' }, + ]); + }); + + it('clears transcript loading after replay before metadata finishes', async () => { + const providers = createDeferred(); + const commands = + createDeferred>>(); + const context = + createDeferred>>(); + sdkMocks.workspaceProviders.mockReturnValueOnce(providers.promise); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + replaySnapshot: createTextReplaySnapshot('restored transcript'), + supportedCommands: vi.fn(() => commands.promise), + context: vi.fn(() => context.promise), + }), + ); + let blocks: readonly DaemonTranscriptBlock[] = []; + let connection: DaemonConnectionState | undefined; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + }); + await act(async () => { + await flushPromises(); + }); + + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'restored transcript' }, + ]); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-a', + loadingTranscript: undefined, + }); + + providers.resolve({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + providers: [], + }); + commands.resolve({ + v: 1, + sessionId: 'session-a', + availableCommands: [], + availableSkills: [], + }); + context.resolve({ + v: 1, + sessionId: 'session-a', + workspaceCwd: '/mock-workspace', + state: {}, + }); + await act(async () => { + await flushPromises(); + }); + }); + + it('ignores stale metadata from a replaced same-id attachment', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + const providersA = createDeferred(); + const commandsA = + createDeferred>>(); + const contextA = + createDeferred>>(); + sdkMocks.workspaceProviders.mockReturnValueOnce(providersA.promise); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + replaySnapshot: createTextReplaySnapshot('session a transcript'), + supportedCommands: vi.fn(() => commandsA.promise), + context: vi.fn(() => contextA.promise), + }), + createMockSession({ + sessionId: 'session-a', + clientId: 'client-b', + replaySnapshot: createTextReplaySnapshot('replacement transcript'), + }), + ); + let blocks: readonly DaemonTranscriptBlock[] = []; + let connection: DaemonConnectionState | undefined; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + clientId: 'client-a', + }); + await act(async () => { + await flushPromises(); + }); + expect(connection).toMatchObject({ sessionId: 'session-a' }); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'session a transcript' }, + ]); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + expect(connection).toMatchObject({ + sessionId: 'session-a', + clientId: 'client-b', + loadingTranscript: undefined, + }); expect(blocks).toMatchObject([ { kind: 'assistant', text: 'replacement transcript' }, ]); @@ -8780,10 +10772,9 @@ describe('DaemonSessionProvider', () => { }); it('does not clear a deferred session created after an empty controlled render', async () => { - sdkMocks.sessions.push( - createMockSession({ sessionId: 'created-session' }), - createMockSession({ sessionId: 'created-session' }), - ); + const created = createMockSession({ sessionId: 'created-session' }); + const attached = createMockSession({ sessionId: 'created-session' }); + sdkMocks.sessions.push(created, attached); let actions: DaemonSessionActions | undefined; let connection: DaemonConnectionState | undefined; @@ -8805,6 +10796,23 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ sessionId: 'created-session' }); expect(sdkMocks.MockDaemonSessionClient.createOrAttach).toHaveBeenCalled(); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + + expect(connection).toMatchObject({ sessionId: 'created-session' }); + expect(created.detach).not.toHaveBeenCalled(); + expect(attached.detach).not.toHaveBeenCalled(); }); it('does not retry a failed controlled session load until the host changes it', async () => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 85ff93abef2..8ac734cee95 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -25,7 +25,9 @@ import { matchTurnEvent, normalizeDaemonEvent, type CreateSessionRequest, + type DaemonCapabilities, type DaemonEvent, + type DaemonFollowupSuggestionData, type DaemonSseConnectReason, type DaemonTranscriptBlock, type DaemonTranscriptState, @@ -36,6 +38,7 @@ import { import { createDaemonSessionActions, getPromptSettledKey, + normalizeWorkspaceIdentity, resolveSessionRestoreTimeouts, } from './actions.js'; import { @@ -76,6 +79,7 @@ import { type TimerRef, } from '../timing.js'; import { + clearSidechannelFollowupSuggestion, parseSidechannelFollowupSuggestion, publishSidechannelFollowupSuggestion, } from '../followupSidechannel.js'; @@ -101,6 +105,7 @@ import type { DaemonSessionContextValue, DaemonSessionNotice, DaemonSessionProviderProps, + DaemonSessionOwnerGuard, DaemonWorkspaceEventSignals, PendingSessionLoad, SettledPrompt, @@ -155,10 +160,307 @@ interface TranscriptHistoryMaterialization { toolBlockByCallId: Record; permissionBlockByRequestId: Record; } - +type TranscriptHistoryState = Omit & { + sessionId?: string; + beforeRecordId?: string; + cursor?: string; +}; +interface SessionRunnerControl { + session?: DaemonSessionClient; + flush(): void; + stop(): void; +} +interface StagedCrossSession { + session: DaemonSessionClient; + capabilities: DaemonCapabilities; + connection: DaemonConnectionState; + transcript: DaemonTranscriptState; + history: TranscriptHistoryState; + signals: DaemonWorkspaceEventSignals; + notices: SessionNoticeInput[]; + dismissNoticeIds: Set; + followupSuggestion?: DaemonFollowupSuggestionData; + midTurnEvents: DaemonEvent[]; + pendingPromptEvents: DaemonEvent[]; + repair?: LiveJournalRepairEpisode; +} +interface CrossSessionTarget { + sessionId: string; + workspaceCwd?: string; + targetClientId?: string; + mode: 'load' | 'resume'; + origin: 'action' | 'controlled'; +} +interface CrossSessionIntent extends CrossSessionTarget { + key: string; + source: DaemonSessionClient; + baseUrl: string; + token?: string; + lifecycle: number; + environmentGeneration: number; + deadlineAt?: number; + timeout?: ReturnType; + retryAttempt?: number; + promise: Promise; + resolve(): void; + reject(error: unknown): void; +} const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; +const CLIENT_IDENTITY_FEATURE = 'client_identity'; const WORKSPACE_ACP_PREHEAT_FEATURE = 'workspace_acp_preheat'; const WORKSPACE_ACP_STATUS_FEATURE = 'workspace_acp_status'; +const STAGING_BATCH_SIZE = 512; +function crossSessionKey( + sessionId: string, + workspaceCwd: string | undefined, +): string { + return `${sessionId}\0${normalizeWorkspaceIdentity(workspaceCwd)}`; +} +function transitionState( + target: CrossSessionTarget, + phase: 'queued' | 'preparing' | 'failed', + error?: NonNullable['error'], +): NonNullable { + return { + phase, + operation: target.mode, + origin: target.origin, + targetSessionId: target.sessionId, + targetWorkspaceCwd: target.workspaceCwd, + targetClientId: target.targetClientId, + ...(error ? { error } : {}), + }; +} +function settleCrossSessionIntent( + intent: CrossSessionIntent, + error?: unknown, +): void { + if (intent.timeout !== undefined) clearTimeout(intent.timeout); + if (error === undefined) intent.resolve(); + else intent.reject(error); +} +function findFirstPersistedRecordId( + session: DaemonSessionClient, +): string | undefined { + for (const type of ['session_update', 'history_truncated'] as const) { + for (const events of [ + session.replaySnapshot.compactedReplay, + session.replaySnapshot.liveJournal, + ]) { + for (const event of events) { + if (event.type !== type) continue; + const id = getPersistedReplayRecordId(event); + if (id !== undefined) return id; + } + } + } + return session.historyAnchorRecordId; +} + +function stageCrossSession(input: { + session: DaemonSessionClient; + capabilities: DaemonCapabilities; + maxBlocks: number; + subagentTranscriptMode: 'full' | 'summary'; + eventOptions: { suppressOwnUserEcho: boolean; includeRawEvent: boolean }; +}): StagedCrossSession { + const { session, capabilities, maxBlocks, subagentTranscriptMode } = input; + const notices: SessionNoticeInput[] = []; + const dismissNoticeIds = new Set(); + let noticeId = 0; + const addStagedNotice: AddDaemonSessionNotice = (notice) => { + const stagedNotice = { + ...notice, + id: notice.id ?? `staged-daemon-notice-${++noticeId}`, + createdAt: notice.createdAt ?? Date.now(), + }; + const existingIndex = notices.findIndex( + (existing) => existing.id === stagedNotice.id, + ); + if (!dismissNoticeIds.delete(stagedNotice.id) && existingIndex >= 0) + return stagedNotice; + if (existingIndex >= 0) notices.splice(existingIndex, 1); + notices.push(stagedNotice); + if (notices.length > 50) notices.shift(); + return stagedNotice; + }; + let connection: DaemonConnectionState = { + status: 'connected', + sessionId: session.sessionId, + ...(session.clientId ? { clientId: session.clientId } : {}), + workspaceCwd: session.workspaceCwd, + displayName: getSessionDisplayName(session.state), + capabilities, + catchingUp: session.lastEventId !== undefined ? true : undefined, + }; + const updateConnection: Dispatch> = ( + update, + ) => { + connection = typeof update === 'function' ? update(connection) : update; + }; + let signals = { ...INITIAL_WORKSPACE_EVENT_SIGNALS }; + const updateSignals: Dispatch> = ( + update, + ) => { + signals = typeof update === 'function' ? update(signals) : update; + }; + const shadow = createDaemonTranscriptStore({ + maxBlocks: Number.MAX_SAFE_INTEGER, + retainSubagentBlocks: subagentTranscriptMode === 'full', + }); + let transcriptBatch: DaemonUiEvent[] = []; + const repairTarget = findLiveJournalRepairTarget( + session.sessionId, + session.replaySnapshot.liveJournal, + session.lastEventId, + session.replayDegraded === true, + ); + let repairCheckpoint: DaemonTranscriptState | undefined; + const midTurnEvents: DaemonEvent[] = []; + const pendingPromptEvents: DaemonEvent[] = []; + let followupSuggestion: DaemonFollowupSuggestionData | undefined; + const observedSnapshotEventIds = new Set(); + const flush = () => { + if (transcriptBatch.length === 0) return; + shadow.dispatch(transcriptBatch); + transcriptBatch = []; + }; + const enqueueTranscript = (events: readonly DaemonUiEvent[]) => { + for (const uiEvent of events) { + transcriptBatch.push(uiEvent); + if (transcriptBatch.length === STAGING_BATCH_SIZE) flush(); + } + }; + const firstPersistedRecordId = findFirstPersistedRecordId(session); + const replayWasTruncated = + session.replaySnapshot.compactedReplay.some( + hasFullTranscriptBeforeReplay, + ) || session.replaySnapshot.liveJournal.some(hasFullTranscriptBeforeReplay); + const historyHasMore = + capabilities.features.includes(SESSION_TRANSCRIPT_PAGINATION_FEATURE) && + (session.historyHasMore || replayWasTruncated) && + firstPersistedRecordId !== undefined; + const replayOpts = { + ...input.eventOptions, + suppressOwnUserEcho: false, + }; + const consume = (event: DaemonEvent) => { + try { + const normalized = normalizeAndFilterEvent( + event, + session.clientId, + replayOpts, + updateConnection, + { suppressLog: true }, + ); + bumpWorkspaceEventSignals(normalized, updateSignals); + let transcript = filterDaemonUiEventsForTranscript( + event, + normalized, + addStagedNotice, + (id) => dismissNoticeIds.add(id), + { hideHistoryTruncation: historyHasMore, suppressLogs: true }, + ); + if (subagentTranscriptMode === 'summary') { + transcript = projectMainTranscriptEvents(transcript); + } + enqueueTranscript(transcript); + if (event.type === 'turn_complete') { + enqueueTranscript([ + assistantDoneFromTurnEvent( + event, + (event.data as DaemonTurnCompleteData | undefined)?.stopReason ?? + 'end_turn', + ), + ]); + } else if (event.type === 'turn_error') { + enqueueTranscript([assistantDoneFromTurnEvent(event, 'error')]); + } + if (parseSidechannelMidTurnInjected(event)) { + midTurnEvents.push(event); + if (midTurnEvents.length > 64) midTurnEvents.shift(); + } + followupSuggestion = + parseSidechannelFollowupSuggestion(event) ?? followupSuggestion; + if (isPendingPromptEvent(event)) { + pendingPromptEvents.push(event); + if (pendingPromptEvents.length > 200) pendingPromptEvents.shift(); + } + } catch (error) { + addStagedNotice({ + severity: 'warning', + category: 'protocol', + operation: 'normalize_event', + code: 'daemon.replay_event_malformed', + message: 'Skipped malformed replay event', + debugMessage: error instanceof Error ? error.message : String(error), + recoverable: true, + }); + } + }; + for (const event of session.replaySnapshot.compactedReplay) consume(event); + for (const event of session.replaySnapshot.liveJournal) { + if (event.id !== undefined) observedSnapshotEventIds.add(event.id); + if (event === repairTarget?.marker) { + flush(); + repairCheckpoint = shadow.getSnapshot(); + } + consume(event); + } + flush(); + const replayTokenUsage = + getReplayTokenUsage(session.replaySnapshot.liveJournal) ?? + getReplayTokenUsage(session.replaySnapshot.compactedReplay); + connection = { + ...connection, + status: 'connected', + displayName: getSessionDisplayName(session.state), + tokenUsage: replayTokenUsage, + tokenCount: getTokenCountFromUsage(replayTokenUsage) ?? 0, + error: undefined, + errorStatus: undefined, + missingSession: false, + sessionTransition: undefined, + }; + const transcript = shadow.getSnapshot(); + const replayExceededCapacity = transcript.blocks.length > maxBlocks; + transcript.maxBlocks = Math.max(maxBlocks, transcript.blocks.length); + if (repairCheckpoint) repairCheckpoint.maxBlocks = transcript.maxBlocks; + const repair = + repairTarget && repairCheckpoint + ? { + sessionId: session.sessionId, + target: repairTarget, + checkpoint: repairCheckpoint, + observedSnapshotEventIds, + snapshotLastEventId: session.lastEventId ?? 0, + lastObservedEventId: session.lastEventId ?? 0, + terminalSeen: false, + attempted: false, + } + : undefined; + return { + session, + capabilities, + connection, + transcript, + history: { + sessionId: session.sessionId, + beforeRecordId: firstPersistedRecordId, + hasMore: historyHasMore && !replayExceededCapacity, + loading: false, + capacityReached: historyHasMore && replayExceededCapacity, + paginationError: false, + }, + signals, + notices, + dismissNoticeIds, + ...(followupSuggestion ? { followupSuggestion } : {}), + midTurnEvents, + pendingPromptEvents, + ...(repair ? { repair } : {}), + }; +} function assistantDoneFromTurnEvent( event: DaemonEvent, @@ -426,6 +728,9 @@ const DaemonSessionNoticesContext = createContext< const DaemonWorkspaceEventSignalsContext = createContext< DaemonWorkspaceEventSignals | undefined >(undefined); +const DaemonSessionOwnerGuardContext = createContext< + DaemonSessionOwnerGuard | undefined +>(undefined); /** * Subset of TERMINAL_SESSION_HTTP_STATUSES that represent **credential * failures** (vs session-not-found 404/410). Auth failures should NOT enter @@ -492,12 +797,40 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { heartbeatIntervalMs = 30_000, heartbeatFailureThreshold = 3, loadWarnings, + onSessionTransitionCommit, children, } = props; const workspace = useOptionalDaemonWorkspace(); const resolvedBaseUrl = baseUrl ?? workspace?.baseUrl; const resolvedToken = token ?? workspace?.token; const resolvedWorkspaceCwd = workspaceCwd ?? workspace?.workspaceCwd; + const environmentRef = useRef({ + baseUrl: resolvedBaseUrl, + token: resolvedToken, + client: workspace?.client, + clientId, + maxBlocks, + subagentTranscriptMode, + generation: 0, + }); + if ( + environmentRef.current.baseUrl !== resolvedBaseUrl || + environmentRef.current.token !== resolvedToken || + environmentRef.current.client !== workspace?.client || + environmentRef.current.clientId !== clientId || + environmentRef.current.maxBlocks !== maxBlocks || + environmentRef.current.subagentTranscriptMode !== subagentTranscriptMode + ) { + environmentRef.current = { + baseUrl: resolvedBaseUrl, + token: resolvedToken, + client: workspace?.client, + clientId, + maxBlocks, + subagentTranscriptMode, + generation: environmentRef.current.generation + 1, + }; + } const workspaceClientRef = useRef(workspace?.client); workspaceClientRef.current = workspace?.client; const workspaceCapabilitiesRef = useRef(workspace?.capabilities); @@ -512,12 +845,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // sessionId prop changes are handled by the controlled-session effect below. const shouldDeferInitialSessionCreation = initialRestoreSessionId === undefined; - const resolvedWorkspaceCwdRef = useRef(resolvedWorkspaceCwd); - resolvedWorkspaceCwdRef.current = resolvedWorkspaceCwd; const activeWorkspaceCwdRef = useRef(resolvedWorkspaceCwd); - if (resolvedWorkspaceCwd) { - activeWorkspaceCwdRef.current = resolvedWorkspaceCwd; - } const store = useMemo( () => @@ -528,26 +856,32 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { [maxBlocks, subagentTranscriptMode], ); const sessionRef = useRef(undefined); - const transcriptHistoryRef = useRef<{ - sessionId?: string; - beforeRecordId?: string; - cursor?: string; - hasMore: boolean; - loading: boolean; - capacityReached: boolean; - paginationError: boolean; - }>({ - hasMore: false, - loading: false, - capacityReached: false, - paginationError: false, - }); - const [transcriptHistoryState, setTranscriptHistoryState] = useState({ + const runnerControlRef = useRef(undefined); + const preparedRunnerRef = useRef(undefined); + const desiredTransitionRef = useRef( + undefined, + ); + if ( + !sessionRef.current && + !desiredTransitionRef.current && + resolvedWorkspaceCwd + ) { + activeWorkspaceCwdRef.current = resolvedWorkspaceCwd; + } + const rawTransitionRef = useRef(undefined); + const pumpTransitionRef = useRef<() => void>(() => undefined); + const lifecycleRef = useRef(0); + const sourceBoundOperationCountRef = useRef(0); + const cancelTransitionRef = useRef<(reason: string) => void>(() => undefined); + const controlledTransitionOriginRef = useRef(false); + const transcriptHistoryRef = useRef({ hasMore: false, loading: false, capacityReached: false, paginationError: false, }); + const [transcriptHistoryState, setTranscriptHistoryState] = + useState(transcriptHistoryRef.current); const eventStreamRef = useRef< | { sessionId: string; @@ -616,9 +950,19 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const [connection, setConnection] = useState({ status: autoConnect ? 'connecting' : 'idle', ...(initialRestoreSessionId ? { sessionId: initialRestoreSessionId } : {}), + ...(resolvedWorkspaceCwd ? { workspaceCwd: resolvedWorkspaceCwd } : {}), }); const connectionRef = useRef(connection); connectionRef.current = connection; + const setConnectionSynchronous = useCallback( + (update: SetStateAction) => { + const next = + typeof update === 'function' ? update(connectionRef.current) : update; + connectionRef.current = next; + setConnection(next); + }, + [], + ); useEffect(() => { if (!workspace?.capabilities) return; setConnection((current) => @@ -660,14 +1004,31 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { useState(INITIAL_WORKSPACE_EVENT_SIGNALS); const hasCurrentSessionActivePromptRef = useRef<() => boolean>(() => false); const mountedRef = useRef(false); + const mountGenerationRef = useRef(0); useEffect(() => { + const generation = ++mountGenerationRef.current; + const mountGeneration = mountGenerationRef; mountedRef.current = true; return () => { mountedRef.current = false; - liveJournalRepairRef.current?.controller?.abort(); - liveJournalRepairRef.current = undefined; - tryLiveJournalRepairRef.current = undefined; + queueMicrotask(() => { + if (mountedRef.current || mountGeneration.current !== generation) { + return; + } + lifecycleRef.current += 1; + const intent = desiredTransitionRef.current; + desiredTransitionRef.current = undefined; + if (intent) { + if (intent.timeout !== undefined) clearTimeout(intent.timeout); + intent.reject( + new DOMException('Session transition interrupted', 'AbortError'), + ); + } + liveJournalRepairRef.current?.controller?.abort(); + liveJournalRepairRef.current = undefined; + tryLiveJournalRepairRef.current = undefined; + }); }; }, []); @@ -762,6 +1123,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { cancelTranscriptFlush(); pendingTranscriptEvents = []; }; + const runnerControl: SessionRunnerControl = { + session: runnerSession, + flush: flushTranscriptSync, + stop: () => abort.abort(), + }; + runnerControlRef.current = runnerControl; const tryLiveJournalRepair = () => { if (disposed || abort.signal.aborted) return; const repair = liveJournalRepairRef.current; @@ -770,6 +1137,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { repair.attempted || !repair.terminalSeen || pendingSessionLoadRef.current || + desiredTransitionRef.current || + rawTransitionRef.current || transcriptHistoryRef.current.loading || sessionRef.current?.sessionId !== repair.sessionId || hasCurrentSessionActivePromptRef.current() @@ -806,10 +1175,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const client = workspaceClientRef.current ?? new DaemonClient({ baseUrl: resolvedBaseUrl!, token: resolvedToken }); - let session: DaemonSessionClient | undefined; + let prepared = + preparedRunnerRef.current?.session === sessionRef.current + ? preparedRunnerRef.current + : undefined; + if (preparedRunnerRef.current === prepared) { + preparedRunnerRef.current = undefined; + } + let session: DaemonSessionClient | undefined = prepared?.session; let capabilities: | Awaited> - | undefined; + | undefined = prepared?.capabilities; let reconnectSessionId = restoreSessionId; let shouldCreateFreshSession = !manualSessionClearRef.current && @@ -923,7 +1299,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { caps.features.includes('client_heartbeat'); const effectWorkspaceCwd = restoreWorkspaceCwd ?? - resolvedWorkspaceCwdRef.current ?? + activeWorkspaceCwdRef.current ?? caps.workspaceCwd; activeWorkspaceCwdRef.current = effectWorkspaceCwd; const capabilityFeatures = Array.isArray(caps.features) @@ -1307,6 +1683,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const activeSession = session; runnerSession = activeSession; + runnerControl.session = activeSession; // Prompt activity is session state returned by /load. Surface it // immediately so a refreshed page shows the running state without // waiting for auxiliary data such as providers, commands, or context. @@ -1354,7 +1731,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // The deferred store.reset() runs here — in the same synchronous // block as store.dispatch() — so the queueMicrotask notification // only fires once with the fully-populated state. - const { compactedReplay, liveJournal } = activeSession.replaySnapshot; + const preparedHandoff = prepared !== undefined; + const { compactedReplay, liveJournal } = preparedHandoff + ? { compactedReplay: [], liveJournal: [] } + : activeSession.replaySnapshot; + prepared = undefined; const replayEvents = [...compactedReplay, ...liveJournal]; const markerStillVisible = repairingEpisode?.markerBlockId !== undefined && @@ -1387,15 +1768,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const replayHistoryWasTruncated = replayEvents.some( hasFullTranscriptBeforeReplay, ); - const historyHasMore = repairingEpisode + const historyHasMore = preparedHandoff ? transcriptHistoryRef.current.hasMore - : Array.isArray(capabilities?.features) && - capabilities.features.includes( - SESSION_TRANSCRIPT_PAGINATION_FEATURE, - ) && - (activeSession.historyHasMore || replayHistoryWasTruncated) && - firstPersistedRecordId !== undefined; - if (!repairingEpisode) { + : repairingEpisode + ? transcriptHistoryRef.current.hasMore + : Array.isArray(capabilities?.features) && + capabilities.features.includes( + SESSION_TRANSCRIPT_PAGINATION_FEATURE, + ) && + (activeSession.historyHasMore || replayHistoryWasTruncated) && + firstPersistedRecordId !== undefined; + if (!repairingEpisode && !preparedHandoff) { transcriptHistoryRef.current = { sessionId: activeSession.sessionId, ...(firstPersistedRecordId !== undefined @@ -1885,6 +2268,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { let sawEvent = false; let resyncRequested = false; const requestEpochResetReload = () => { + cancelTransitionRef.current( + 'Session transition cancelled by state resync', + ); // An epoch reset means the daemon/EventBus timeline was rebuilt. // The current SSE cursor and any restored/local prompt activity may // describe the old epoch, so do a full /load and let @@ -2187,6 +2573,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ? (event.data as Record).reason : undefined; if (reason !== 'epoch_reset') { + cancelTransitionRef.current( + 'Session transition cancelled by state resync', + ); // Resync asks us to rebuild transcript state, but it is not a // prompt terminal signal. Keep loading alive for local/restored // prompts until turn_complete, turn_error, or prompt_cancelled. @@ -2371,20 +2760,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { error instanceof Error ? error.message : String(error); const errorStatus = extractHttpStatus(error); const pendingLoad = pendingSessionLoadRef.current; - const errorBody = - error instanceof DaemonHttpError && isRecord(error.body) - ? error.body - : undefined; if ( autoReconnect && loadingRequestedSession && pendingLoad?.sessionId === restoreSessionId && - error instanceof DaemonHttpError && - error.status === 404 && - typeof errorBody?.['error'] === 'string' && - errorBody['error'].endsWith( - 'The session is closing; retry after close completes', - ) + isClosingSessionLoadError(error) ) { reconnectAttempt += 1; const reconnectConfig = reconnectConfigRef.current; @@ -2569,16 +2949,18 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { void run(); return () => { const session = runnerSession; + if (desiredTransitionRef.current && sessionRef.current === session) { + cancelTransitionRef.current('Session runner restarted'); + } + if (runnerControlRef.current === runnerControl) { + runnerControlRef.current = undefined; + } disposed = true; abort.abort(); const ownsCurrentSession = session !== undefined && sessionRef.current === session; const ownsEmptyState = session === undefined && sessionRef.current === undefined; - const keepSessionForNextEffect = - ownsCurrentSession && - session === skipNextCleanupDetachSessionRef.current; - const isUnmounting = !mountedRef.current; if (ownsCurrentSession || ownsEmptyState) { // A same-attachment effect restart must flush events already yielded by // the SSE client, because its resume cursor has advanced past them. @@ -2588,41 +2970,62 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // runner's pending macrotask append its buffered events to that owner. clearPendingTranscriptEvents(); } - if (ownsCurrentSession && (!keepSessionForNextEffect || isUnmounting)) { - hasCurrentSessionActivePromptRef.current = () => false; - setPromptStatus('idle'); - clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); - } - if ( - effectPendingSessionLoad !== undefined && - pendingSessionLoadRef.current === effectPendingSessionLoad && - (ownsCurrentSession || ownsEmptyState) && - (!keepSessionForNextEffect || isUnmounting) - ) { - if (pendingSessionLoadRef.current.timeout !== undefined) { - clearTimeout(pendingSessionLoadRef.current.timeout); + const releaseOwnedSession = (unmounting: boolean) => { + const ownedSession = unmounting ? sessionRef.current : session; + const stillOwnsSession = + ownedSession !== undefined && sessionRef.current === ownedSession; + const stillOwnsEmptyState = + session === undefined && sessionRef.current === undefined; + if (!stillOwnsSession && !stillOwnsEmptyState) return; + const keepSessionForNextEffect = + !unmounting && + stillOwnsSession && + ownedSession === skipNextCleanupDetachSessionRef.current; + if (keepSessionForNextEffect) return; + if (stillOwnsSession) { + hasCurrentSessionActivePromptRef.current = () => false; + setPromptStatus('idle'); + clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); } - pendingSessionLoadRef.current.reject( - new DOMException('Session load interrupted by cleanup', 'AbortError'), - ); - pendingSessionLoadRef.current = undefined; - } - if ( - ownsCurrentSession && - (!keepSessionForNextEffect || isUnmounting) && - session.clientId - ) { - void detachDaemonClient({ - baseUrl: resolvedBaseUrl!, - token: resolvedToken, - sessionId: session.sessionId, - clientId: session.clientId, - }).catch((err) => - console.warn('[DaemonSessionProvider] detach failed:', err), - ); - } - if (ownsCurrentSession && (!keepSessionForNextEffect || isUnmounting)) { - sessionRef.current = undefined; + const pendingLoad = pendingSessionLoadRef.current; + if ( + pendingLoad && + (unmounting || pendingLoad === effectPendingSessionLoad) && + (stillOwnsEmptyState || + (stillOwnsSession && + ownedSession.sessionId === pendingLoad.sessionId)) + ) { + if (pendingLoad.timeout !== undefined) { + clearTimeout(pendingLoad.timeout); + } + pendingLoad.reject( + new DOMException( + 'Session load interrupted by cleanup', + 'AbortError', + ), + ); + pendingSessionLoadRef.current = undefined; + } + if (stillOwnsSession) { + if (ownedSession.clientId) { + void detachDaemonClient({ + baseUrl: resolvedBaseUrl!, + token: resolvedToken, + sessionId: ownedSession.sessionId, + clientId: ownedSession.clientId, + }).catch((err) => + console.warn('[DaemonSessionProvider] detach failed:', err), + ); + } + sessionRef.current = undefined; + } + }; + if (!mountedRef.current) { + queueMicrotask(() => { + if (!mountedRef.current) releaseOwnedSession(true); + }); + } else { + releaseOwnedSession(false); } }; }, [ @@ -2630,7 +3033,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { autoReconnect, resolvedBaseUrl, resolvedToken, - workspaceCwd, modelServiceId, sessionScope, maxQueued, @@ -2795,6 +3197,478 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { heartbeatIntervalMs, ]); + const publishCrossSessionFailure = useCallback( + (target: CrossSessionTarget, error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const status = extractHttpStatus(error); + const code = + error instanceof DaemonHttpError && + isRecord(error.body) && + typeof error.body['code'] === 'string' + ? error.body['code'] + : undefined; + setConnectionSynchronous((current) => ({ + ...current, + sessionTransition: transitionState(target, 'failed', { + message, + ...(code !== undefined ? { code } : {}), + ...(status !== undefined ? { status } : {}), + }), + })); + addNotice({ + severity: 'warning', + category: 'connection', + operation: target.mode === 'resume' ? 'resume_session' : 'load_session', + code: 'daemon.session_transition.failed', + message: `Could not open session ${target.sessionId}. The current session is still active.`, + debugMessage: message, + recoverable: true, + }); + }, + [addNotice, setConnectionSynchronous], + ); + + const exposeCrossSessionFailure = useCallback( + (intent: CrossSessionIntent, error: unknown) => { + if (desiredTransitionRef.current !== intent) return; + desiredTransitionRef.current = undefined; + if (mountedRef.current) publishCrossSessionFailure(intent, error); + settleCrossSessionIntent(intent, error); + }, + [publishCrossSessionFailure], + ); + + const retireAttachment = useCallback( + (session: DaemonSessionClient, intent: CrossSessionIntent) => { + const clientId = session.clientId || intent.targetClientId; + if (!clientId) return; + void detachDaemonClient({ + baseUrl: intent.baseUrl, + token: intent.token, + sessionId: session.sessionId || intent.sessionId, + clientId, + }).catch((error: unknown) => { + console.warn('[DaemonSessionProvider] detach failed:', error); + }); + }, + [], + ); + + const commitCrossSession = useCallback( + (intent: CrossSessionIntent, staged: StagedCrossSession): boolean => { + if ( + !mountedRef.current || + desiredTransitionRef.current !== intent || + intent.lifecycle !== lifecycleRef.current || + intent.environmentGeneration !== environmentRef.current.generation || + (intent.deadlineAt !== undefined && Date.now() >= intent.deadlineAt) + ) { + return false; + } + const current = sessionRef.current; + if ( + current !== undefined && + (!current.clientId || + current.sessionId !== intent.source.sessionId || + normalizeWorkspaceIdentity(current.workspaceCwd) !== + normalizeWorkspaceIdentity(intent.source.workspaceCwd)) + ) { + return false; + } + const sourceToRetire = current ?? intent.source; + if (intent.timeout !== undefined) clearTimeout(intent.timeout); + if (runnerControlRef.current?.session === sourceToRetire) { + runnerControlRef.current.flush(); + runnerControlRef.current.stop(); + } + store.reset(staged.transcript); + transcriptHistoryRef.current = staged.history; + setTranscriptHistoryState({ + hasMore: staged.history.hasMore, + loading: false, + capacityReached: staged.history.capacityReached, + paginationError: false, + }); + sessionRef.current = staged.session; + lastSessionIdRef.current = staged.session.sessionId; + activeWorkspaceCwdRef.current = staged.session.workspaceCwd; + clientIdRef.current = staged.session.clientId; + persistStableClientId(staged.session.clientId!, staged.session.sessionId); + setConnectionSynchronous(staged.connection); + desiredTransitionRef.current = undefined; + try { + onSessionTransitionCommit?.({ + sessionId: staged.session.sessionId, + workspaceCwd: staged.session.workspaceCwd, + }); + } catch (error) { + console.warn('[DaemonSessionProvider] commit observer failed:', error); + } + clearSidechannelFollowupSuggestion(); + if (staged.followupSuggestion) { + publishSidechannelFollowupSuggestion(staged.followupSuggestion); + } + clearNotices(); + for (const notice of staged.notices) addNotice(notice); + for (const id of staged.dismissNoticeIds) dismissNotice(id); + setWorkspaceEventSignals((currentSignals) => ({ + memoryVersion: + currentSignals.memoryVersion + staged.signals.memoryVersion, + agentsVersion: + currentSignals.agentsVersion + staged.signals.agentsVersion, + toolsVersion: currentSignals.toolsVersion + staged.signals.toolsVersion, + settingsVersion: + currentSignals.settingsVersion + staged.signals.settingsVersion, + mcpVersion: currentSignals.mcpVersion + staged.signals.mcpVersion, + extensionsVersion: + currentSignals.extensionsVersion + staged.signals.extensionsVersion, + artifactsVersion: + currentSignals.artifactsVersion + staged.signals.artifactsVersion, + initVersion: currentSignals.initVersion + staged.signals.initVersion, + authVersion: currentSignals.authVersion + staged.signals.authVersion, + ...(staged.signals.lastExtensionChange + ? { lastExtensionChange: staged.signals.lastExtensionChange } + : {}), + })); + for (const event of staged.midTurnEvents) { + const injected = parseSidechannelMidTurnInjected(event); + if (injected) publishSidechannelMidTurnInjected(injected); + } + for (const event of staged.pendingPromptEvents) { + publishPendingPromptEvent(event); + } + const active = activePromptsRef.current.get(sourceToRetire.sessionId); + active?.controller.abort( + new DOMException( + 'Session switch interrupted prompt wait', + 'AbortError', + ), + ); + activePromptsRef.current.delete(sourceToRetire.sessionId); + settledPromptsRef.current.clear(); + hasCurrentSessionActivePromptRef.current = () => + staged.session.hasActivePrompt === true; + clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); + setPromptStatus(staged.session.hasActivePrompt ? 'streaming' : 'idle'); + liveJournalRepairRef.current?.controller?.abort(); + liveJournalRepairRef.current = staged.repair; + preparedRunnerRef.current = staged; + manualSessionClearRef.current = false; + setRestoreMode(intent.mode); + setRestoreSessionId(staged.session.sessionId); + setRestoreWorkspaceCwd(staged.session.workspaceCwd); + setRestoreSessionNonce((nonce) => nonce + 1); + settleCrossSessionIntent(intent); + retireAttachment(sourceToRetire, intent); + return true; + }, + [ + addNotice, + clearNotices, + dismissNotice, + onSessionTransitionCommit, + retireAttachment, + setConnectionSynchronous, + store, + ], + ); + + const pumpCrossSessionTransition = useCallback(() => { + if (rawTransitionRef.current) return; + const intent = desiredTransitionRef.current; + if (!intent) return; + if ( + intent.lifecycle !== lifecycleRef.current || + intent.environmentGeneration !== environmentRef.current.generation + ) { + exposeCrossSessionFailure( + intent, + new DOMException( + 'Session transition environment changed', + 'AbortError', + ), + ); + return; + } + if (intent.deadlineAt !== undefined && Date.now() >= intent.deadlineAt) { + exposeCrossSessionFailure( + intent, + new Error('Session transition timed out before restore started'), + ); + return; + } + const capabilities = + workspaceCapabilitiesRef.current ?? connectionRef.current.capabilities; + if (!capabilities?.features.includes(CLIENT_IDENTITY_FEATURE)) return; + const requestClientId = getStableClientId(clientId, intent.sessionId); + intent.targetClientId = requestClientId; + rawTransitionRef.current = intent; + setConnectionSynchronous((current) => ({ + ...current, + sessionTransition: transitionState(intent, 'preparing'), + })); + const client = + workspaceClientRef.current ?? + new DaemonClient({ baseUrl: resolvedBaseUrl!, token: resolvedToken }); + const remaining = + intent.deadlineAt === undefined + ? undefined + : Math.max(1, intent.deadlineAt - Date.now()); + const requestBudget = + resolveSessionRestoreTimeouts(capabilities).requestTimeoutMs; + const timeoutMs = + requestBudget === 0 + ? (remaining ?? 0) + : remaining === undefined + ? requestBudget + : Math.min(requestBudget, remaining); + const restore = + intent.mode === 'resume' + ? DaemonSessionClient.resume + : DaemonSessionClient.load; + let retryScheduled = false; + void restore( + client, + intent.sessionId, + { + workspaceCwd: intent.workspaceCwd, + timeoutMs, + ...(intent.mode === 'load' && + historyPageSizeRef.current !== undefined && + capabilities.features.includes(SESSION_TRANSCRIPT_PAGINATION_FEATURE) + ? { historyPageSize: historyPageSizeRef.current } + : {}), + }, + requestClientId, + ) + .then((candidate) => { + const latest = desiredTransitionRef.current; + if ( + !candidate.clientId || + candidate.sessionId !== intent.sessionId || + normalizeWorkspaceIdentity(candidate.workspaceCwd) !== + normalizeWorkspaceIdentity(intent.workspaceCwd) + ) { + retireAttachment(candidate, intent); + if (latest === intent) { + exposeCrossSessionFailure( + latest, + new Error('Session restore returned an invalid owner identity'), + ); + } + return; + } + if ( + latest?.key !== intent.key || + latest?.environmentGeneration !== intent.environmentGeneration + ) { + retireAttachment(candidate, intent); + return; + } + let staged: StagedCrossSession; + try { + staged = stageCrossSession({ + session: candidate, + capabilities, + maxBlocks, + subagentTranscriptMode: subagentTranscriptModeRef.current, + eventOptions: eventOptionsRef.current, + }); + } catch (error) { + retireAttachment(candidate, intent); + exposeCrossSessionFailure(latest, error); + return; + } + if (!commitCrossSession(latest, staged)) { + retireAttachment(candidate, intent); + exposeCrossSessionFailure( + latest, + new DOMException( + 'Session transition became stale before commit', + 'AbortError', + ), + ); + } + }) + .catch((error: unknown) => { + const latest = desiredTransitionRef.current; + if ( + autoReconnect && + latest === intent && + isClosingSessionLoadError(error) + ) { + retryScheduled = true; + intent.retryAttempt = (intent.retryAttempt ?? 0) + 1; + const reconnectConfig = reconnectConfigRef.current; + setTimeout( + pumpTransitionRef.current, + getReconnectDelayMs( + intent.retryAttempt, + reconnectConfig.reconnectDelayMs, + reconnectConfig.maxReconnectDelayMs, + ), + ); + } else if (latest === intent) { + exposeCrossSessionFailure(latest, error); + } + }) + .finally(() => { + if (rawTransitionRef.current === intent) { + rawTransitionRef.current = undefined; + } + if (!retryScheduled) pumpTransitionRef.current(); + }); + }, [ + autoReconnect, + clientId, + commitCrossSession, + exposeCrossSessionFailure, + maxBlocks, + resolvedBaseUrl, + resolvedToken, + retireAttachment, + setConnectionSynchronous, + ]); + pumpTransitionRef.current = pumpCrossSessionTransition; + + const cancelCrossSessionTransition = useCallback( + (reason: string) => { + lifecycleRef.current += 1; + const intent = desiredTransitionRef.current; + desiredTransitionRef.current = undefined; + if (intent) { + settleCrossSessionIntent( + intent, + new DOMException(reason, 'AbortError'), + ); + } + setConnectionSynchronous((current) => { + if (!current.sessionTransition) return current; + const next = { ...current }; + delete next.sessionTransition; + return next; + }); + }, + [setConnectionSynchronous], + ); + cancelTransitionRef.current = cancelCrossSessionTransition; + + const beginCrossSessionTransition = useCallback( + ( + request: CrossSessionTarget, + startLegacy: () => Promise, + ): Promise => { + const capabilities = + workspaceCapabilitiesRef.current ?? connectionRef.current.capabilities; + const rejectPreflight = (error: Error) => { + publishCrossSessionFailure(request, error); + return Promise.reject(error); + }; + if (!capabilities) { + return rejectPreflight( + new Error( + 'Daemon capabilities are unavailable; current session was preserved', + ), + ); + } + if (!capabilities.features.includes(CLIENT_IDENTITY_FEATURE)) { + return startLegacy(); + } + if (!resolvedBaseUrl) { + return rejectPreflight( + new Error( + 'Daemon endpoint is unavailable; current session was preserved', + ), + ); + } + const source = sessionRef.current; + if (!source?.clientId) { + return rejectPreflight( + new Error( + 'The daemon advertises client identity but the current session has no clientId', + ), + ); + } + if (sourceBoundOperationCountRef.current > 0) { + return rejectPreflight( + new DOMException( + 'Another session operation is already in progress', + 'InvalidStateError', + ), + ); + } + if (pendingSessionLoadRef.current) { + return rejectPreflight( + new DOMException( + 'Another session restore is already in progress', + 'InvalidStateError', + ), + ); + } + const key = crossSessionKey(request.sessionId, request.workspaceCwd); + const current = desiredTransitionRef.current; + if (current?.key === key) return current.promise; + if (current) { + settleCrossSessionIntent( + current, + new DOMException( + 'Session transition superseded by a newer request', + 'AbortError', + ), + ); + } + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const timeouts = resolveSessionRestoreTimeouts(capabilities); + const deadlineAt = + timeouts.watchdogTimeoutMs === undefined + ? undefined + : Date.now() + timeouts.watchdogTimeoutMs; + const intent: CrossSessionIntent = { + key, + ...request, + source, + baseUrl: resolvedBaseUrl, + token: resolvedToken, + lifecycle: lifecycleRef.current, + environmentGeneration: environmentRef.current.generation, + ...(deadlineAt !== undefined ? { deadlineAt } : {}), + promise, + resolve, + reject, + }; + if (timeouts.watchdogTimeoutMs !== undefined) { + intent.timeout = setTimeout(() => { + exposeCrossSessionFailure( + intent, + new Error('Session transition timed out'), + ); + }, timeouts.watchdogTimeoutMs); + } + desiredTransitionRef.current = intent; + setConnectionSynchronous((connectionState) => ({ + ...connectionState, + sessionTransition: transitionState( + request, + rawTransitionRef.current ? 'queued' : 'preparing', + ), + })); + pumpTransitionRef.current(); + return promise; + }, + [ + exposeCrossSessionFailure, + publishCrossSessionFailure, + resolvedBaseUrl, + resolvedToken, + setConnectionSynchronous, + ], + ); + const actions = useMemo( () => createDaemonSessionActions({ @@ -2878,6 +3752,19 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setRestoreSessionNonce, setAttachSessionNonce, setNewSessionNonce, + beginCrossSessionTransition, + cancelCrossSessionTransition, + isCrossSessionTransitionPending: () => + desiredTransitionRef.current !== undefined, + isSourceBoundOperationInFlight: () => + sourceBoundOperationCountRef.current > 0, + setSourceBoundOperationInFlight: (inFlight) => + (sourceBoundOperationCountRef.current += inFlight ? 1 : -1), + getTransitionOrigin: () => { + const controlled = controlledTransitionOriginRef.current; + controlledTransitionOriginRef.current = false; + return controlled ? 'controlled' : 'action'; + }, clearLiveJournalRepair: () => { liveJournalRepairRef.current?.controller?.abort(); liveJournalRepairRef.current = undefined; @@ -2885,6 +3772,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }), [ addNotice, + beginCrossSessionTransition, + cancelCrossSessionTransition, clientId, resolvedBaseUrl, resolvedToken, @@ -3102,16 +3991,60 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const lastHandledSessionIdRef = useRef< string | undefined | typeof UNHANDLED_SESSION >(UNHANDLED_SESSION); + const lastHandledWorkspaceRef = useRef(undefined); useEffect(() => { - if (lastHandledSessionIdRef.current === sessionId) return; + const targetWorkspaceCwd = + resolvedWorkspaceCwd ?? connectionRef.current.workspaceCwd; + const previousSessionId = lastHandledSessionIdRef.current; + if ( + lastHandledSessionIdRef.current === sessionId && + normalizeWorkspaceIdentity(lastHandledWorkspaceRef.current) === + normalizeWorkspaceIdentity(targetWorkspaceCwd) + ) { + return; + } lastHandledSessionIdRef.current = sessionId; + lastHandledWorkspaceRef.current = targetWorkspaceCwd; + + if (sessionId === undefined && previousSessionId === undefined) return; + + const pending = desiredTransitionRef.current; + if ( + pending && + (pending.sessionId !== sessionId || + normalizeWorkspaceIdentity(pending.workspaceCwd) !== + normalizeWorkspaceIdentity(targetWorkspaceCwd)) + ) { + cancelTransitionRef.current( + 'Session transition cancelled by controlled target change', + ); + } const currentSessionId = connectionRef.current.sessionId; - if (sessionId === currentSessionId) return; + if ( + sessionId === currentSessionId && + normalizeWorkspaceIdentity(targetWorkspaceCwd) === + normalizeWorkspaceIdentity(connectionRef.current.workspaceCwd) + ) { + if (connectionRef.current.sessionTransition?.phase === 'failed') { + setConnectionSynchronous((current) => { + if (current.sessionTransition?.phase !== 'failed') return current; + const next = { ...current }; + delete next.sessionTransition; + return next; + }); + } + return; + } + if (sessionId) controlledTransitionOriginRef.current = true; const request = sessionId - ? actions.loadSession(sessionId) + ? actions.loadSession(sessionId, { + ...(targetWorkspaceCwd !== undefined + ? { workspaceCwd: targetWorkspaceCwd } + : {}), + }) : currentSessionId ? actions.clearSession() : undefined; @@ -3124,7 +4057,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { error, ); }); - }, [actions, sessionId]); + }, [actions, resolvedWorkspaceCwd, sessionId, setConnectionSynchronous]); + + const ownerGuardValue = useMemo( + () => ({ + capture: () => { + const session = sessionRef.current; + return { isCurrent: () => sessionRef.current === session }; + }, + }), + [], + ); return ( @@ -3135,11 +4078,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { value={workspaceEventSignals} > - - {children} - + + {children} + + @@ -3298,6 +4245,7 @@ function filterDaemonUiEventsForTranscript( behavior: { hideHistoryTruncation?: boolean; suppressSideEffects?: boolean; + suppressLogs?: boolean; } = {}, ): DaemonUiEvent[] { if (behavior.hideHistoryTruncation && isHistoricalReplayMarker(sourceEvent)) { @@ -3328,7 +4276,10 @@ function filterDaemonUiEventsForTranscript( const notice = addNotice( daemonErrorEventToNotice(sourceEvent, event as DaemonUiErrorEvent), ); - if (notice.category === 'protocol' || notice.category === 'connection') { + if ( + !behavior.suppressLogs && + (notice.category === 'protocol' || notice.category === 'connection') + ) { console.warn('[DaemonSessionProvider] daemon notice:', notice); } } @@ -3494,6 +4445,16 @@ export function useOptionalDaemonActions(): DaemonSessionActions | undefined { return useContext(DaemonActionsContext); } +export function useDaemonSessionOwnerGuard(): DaemonSessionOwnerGuard { + const guard = useContext(DaemonSessionOwnerGuardContext); + if (!guard) { + throw new Error( + 'useDaemonSessionOwnerGuard must be used within DaemonSessionProvider', + ); + } + return guard; +} + export function useDaemonWorkspaceEventSignals(): | DaemonWorkspaceEventSignals | undefined { @@ -3767,3 +4728,14 @@ function isAuthFailureHttpError(error: unknown): boolean { const status = extractHttpStatus(error); return status !== undefined && AUTH_FAILURE_HTTP_STATUSES.has(status); } + +function isClosingSessionLoadError(error: unknown): boolean { + if (!(error instanceof DaemonHttpError) || error.status !== 404) return false; + const body = isRecord(error.body) ? error.body : undefined; + return ( + typeof body?.['error'] === 'string' && + body['error'].endsWith( + 'The session is closing; retry after close completes', + ) + ); +} diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index c938c8b80de..22108ce95db 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -168,6 +168,83 @@ describe('resolveSessionRestoreTimeouts', () => { }); describe('createDaemonSessionActions', () => { + it('rejects a concurrent source-bound branch request', async () => { + const source = createMockSession('session-a', 'client-a'); + const first = createDeferred<{ + sessionId: string; + displayName: string; + clientId: string; + }>(); + source.client.branchSession.mockReturnValueOnce(first.promise); + let sourceBoundOperationCount = 0; + const setSourceBoundOperationInFlight = vi.fn((inFlight: boolean) => { + sourceBoundOperationCount += inFlight ? 1 : -1; + }); + const { actions } = createActionsHarness({ + beginCrossSessionTransition: vi.fn(async () => undefined), + isSourceBoundOperationInFlight: () => sourceBoundOperationCount > 0, + session: source, + setSourceBoundOperationInFlight, + }); + + const firstBranch = actions.branchSession('First'); + const secondBranch = actions.branchSession('Second'); + await expect(secondBranch).rejects.toMatchObject({ + name: 'InvalidStateError', + }); + expect(source.client.branchSession).toHaveBeenCalledOnce(); + expect(setSourceBoundOperationInFlight.mock.calls).toEqual([[true]]); + + first.resolve({ + sessionId: 'session-b', + displayName: 'First', + clientId: 'client-b', + }); + await expect(firstBranch).resolves.toEqual({ + sessionId: 'session-b', + displayName: 'First', + }); + expect(setSourceBoundOperationInFlight.mock.calls).toEqual([ + [true], + [false], + ]); + }); + + it('does not open a branch that resolves after its source is cleared', async () => { + const source = createMockSession('session-a', 'client-a'); + const branched = createDeferred<{ + sessionId: string; + displayName: string; + clientId: string; + }>(); + source.client.branchSession.mockReturnValueOnce(branched.promise); + const beginCrossSessionTransition = vi.fn(async () => undefined); + const { actions, sessionRef } = createActionsHarness({ + beginCrossSessionTransition, + session: source, + }); + + const pending = actions.branchSession('Late branch'); + await actions.clearSession(); + branched.resolve({ + sessionId: 'session-b', + displayName: 'Late branch', + clientId: 'client-b', + }); + + await expect(pending).resolves.toEqual({ + sessionId: 'session-b', + displayName: 'Late branch', + }); + await Promise.resolve(); + expect(sessionRef.current).toBeUndefined(); + expect(beginCrossSessionTransition).not.toHaveBeenCalled(); + expect(source.client.detachSession).toHaveBeenCalledWith( + 'session-b', + 'client-b', + ); + }); + it('creates from the active session client when the connection matches', async () => { const existingSession = createMockSession('session-a'); const nextSession = createMockSession('session-b'); @@ -513,6 +590,60 @@ describe('createDaemonSessionActions', () => { expect(setRestoreWorkspaceCwd).toHaveBeenCalledWith('/workspace/secondary'); }); + it('does not collapse the filesystem root into an unknown workspace', async () => { + const beginCrossSessionTransition = vi.fn(async () => undefined); + const { actions } = createActionsHarness({ + beginCrossSessionTransition, + connection: { status: 'connected' }, + session: { + ...createMockSession('session-a'), + workspaceCwd: '/', + }, + }); + + await actions.loadSession('session-a'); + + expect(beginCrossSessionTransition).toHaveBeenCalledOnce(); + }); + + it('does not restart the current session while a target switch is preparing', async () => { + const source = createMockSession('session-a', 'client-a'); + const beginCrossSessionTransition = vi.fn(async () => undefined); + const { actions, pendingSessionLoadRef } = createActionsHarness({ + beginCrossSessionTransition, + isCrossSessionTransitionPending: () => true, + connection: { + status: 'connected', + sessionId: 'session-a', + workspaceCwd: '/workspace', + }, + session: source, + }); + + await expect(actions.loadSession('session-a')).rejects.toMatchObject({ + name: 'InvalidStateError', + }); + expect(beginCrossSessionTransition).not.toHaveBeenCalled(); + expect(pendingSessionLoadRef.current).toBeUndefined(); + expect(source.detach).not.toHaveBeenCalled(); + }); + + it('consumes the controlled origin when a switch uses the legacy path', () => { + const getTransitionOrigin = vi.fn(() => 'controlled' as const); + const { actions, pendingSessionLoadRef } = createActionsHarness({ + getTransitionOrigin, + }); + + void actions.loadSession('session-b').catch(() => undefined); + + expect(getTransitionOrigin).toHaveBeenCalledOnce(); + clearTimeout(pendingSessionLoadRef.current?.timeout); + pendingSessionLoadRef.current?.reject( + new DOMException('Test cleanup', 'AbortError'), + ); + pendingSessionLoadRef.current = undefined; + }); + it('clears transcript loading when a session switch fails', async () => { vi.useFakeTimers(); try { @@ -930,6 +1061,114 @@ describe('createDaemonSessionActions', () => { await expect(actions.getMidTurnMessages()).resolves.toBeUndefined(); }); + it('settles a prompt after a same-logical attachment replacement', async () => { + const source = createMockSession('session-a', 'client-a'); + const target = createMockSession('session-a', 'client-b'); + const admitted = createDeferred<{ promptId: string }>(); + source.submitPrompt.mockReturnValueOnce(admitted.promise); + const { actions, sessionRef, store } = createActionsHarness({ + session: source, + }); + + const pending = actions.sendPrompt('hello'); + sessionRef.current = target as unknown as DaemonSessionClient; + admitted.reject(new DOMException('source retired', 'AbortError')); + + await expect(pending).resolves.toEqual({ stopReason: 'cancelled' }); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'assistant.done' }), + ); + }); + + it('keeps a replacement active prompt streaming when the old attachment aborts', async () => { + const source = createMockSession('session-a', 'client-a'); + const target = createMockSession('session-a', 'client-b'); + const admitted = createDeferred<{ promptId: string }>(); + source.submitPrompt.mockReturnValueOnce(admitted.promise); + const setPromptStatus = vi.fn(); + const { actions, sessionRef, store } = createActionsHarness({ + hasSessionActivePrompt: () => true, + session: source, + setPromptStatus, + }); + + const pending = actions.sendPrompt('hello'); + sessionRef.current = target as unknown as DaemonSessionClient; + admitted.reject(new DOMException('source retired', 'AbortError')); + + await expect(pending).resolves.toEqual({ stopReason: 'cancelled' }); + expect(store.dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'assistant.done' }), + ); + expect(setPromptStatus).not.toHaveBeenCalledWith('idle'); + }); + + it('does not settle a prompt after a different-workspace replacement', async () => { + const source = createMockSession('session-a', 'client-a'); + const target = createMockSession('session-a', 'client-b'); + target.workspaceCwd = '/other-workspace'; + const admitted = createDeferred<{ promptId: string }>(); + source.submitPrompt.mockReturnValueOnce(admitted.promise); + const { actions, sessionRef, store } = createActionsHarness({ + session: source, + }); + + const pending = actions.sendPrompt('hello'); + sessionRef.current = target as unknown as DaemonSessionClient; + admitted.reject(new DOMException('source retired', 'AbortError')); + + await expect(pending).resolves.toEqual({ stopReason: 'cancelled' }); + expect(store.dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'assistant.done' }), + ); + }); + + it('settles cancel after a same-logical attachment replacement', async () => { + const source = createMockSession('session-a', 'client-a'); + const target = createMockSession('session-a', 'client-b'); + const cancelled = createDeferred(); + source.cancel.mockReturnValueOnce(cancelled.promise); + const setPromptStatus = vi.fn(); + const { actions, sessionRef } = createActionsHarness({ + activePrompts: new Map([ + ['session-a', { controller: new AbortController() } as ActivePrompt], + ]), + session: source, + setPromptStatus, + }); + + const pending = actions.cancel(); + sessionRef.current = target as unknown as DaemonSessionClient; + cancelled.resolve(undefined); + + await expect(pending).resolves.toBeUndefined(); + expect(setPromptStatus).toHaveBeenLastCalledWith('idle'); + }); + + it('reports a shell failure after a same-logical attachment replacement', async () => { + const source = createMockSession('session-a', 'client-a'); + const target = createMockSession('session-a', 'client-b'); + const shell = createDeferred(); + source.shellCommand.mockReturnValueOnce(shell.promise); + const addNotice = vi.fn((notice) => notice); + const setPromptStatus = vi.fn(); + const { actions, sessionRef } = createActionsHarness({ + addNotice, + session: source, + setPromptStatus, + }); + + const pending = actions.sendShellCommand('echo hello'); + sessionRef.current = target as unknown as DaemonSessionClient; + shell.reject(new Error('shell failed')); + + await expect(pending).rejects.toThrow('shell failed'); + expect(addNotice).toHaveBeenCalledWith( + expect.objectContaining({ operation: 'send_shell_command' }), + ); + expect(setPromptStatus).toHaveBeenLastCalledWith('idle'); + }); + it('does not apply a late model update to a replacement attachment', async () => { const source = createMockSession('session-a', 'client-a'); const target = createMockSession('session-a', 'client-b'); @@ -1076,9 +1315,11 @@ function createActionsHarness( opts: { activePrompts?: Map; addNotice?: ReturnType; + beginCrossSessionTransition?: ReturnType; clearLiveJournalRepair?: ReturnType; connection?: DaemonConnectionState; createDetachedSession?: ReturnType; + getTransitionOrigin?: () => 'action' | 'controlled'; manualSessionClearRef?: { current: boolean }; pendingSessionLoadRef?: { current: PendingSessionLoad | undefined }; restartEventStream?: ReturnType; @@ -1086,6 +1327,11 @@ function createActionsHarness( setAttachSessionNonce?: ReturnType; setRestoreSessionId?: ReturnType; setRestoreWorkspaceCwd?: ReturnType; + setSourceBoundOperationInFlight?: ReturnType; + isSourceBoundOperationInFlight?: () => boolean; + isCrossSessionTransitionPending?: () => boolean; + setPromptStatus?: ReturnType; + hasSessionActivePrompt?: () => boolean; } = {}, ) { let connection: DaemonConnectionState = opts.connection ?? { @@ -1131,15 +1377,20 @@ function createActionsHarness( ) as unknown as DaemonSessionClient, )) as () => Promise, getConnection: () => connection, - hasSessionActivePrompt: () => false, + hasSessionActivePrompt: opts.hasSessionActivePrompt ?? (() => false), resetCurrentSessionActivePrompt: vi.fn(), restartEventStream: opts.restartEventStream ?? vi.fn(), addNotice: opts.addNotice ?? vi.fn(), clearLiveJournalRepair: opts.clearLiveJournalRepair, + beginCrossSessionTransition: opts.beginCrossSessionTransition, + isCrossSessionTransitionPending: opts.isCrossSessionTransitionPending, + isSourceBoundOperationInFlight: opts.isSourceBoundOperationInFlight, + getTransitionOrigin: opts.getTransitionOrigin, + setSourceBoundOperationInFlight: opts.setSourceBoundOperationInFlight, setConnection: (update) => { connection = typeof update === 'function' ? update(connection) : update; }, - setPromptStatus: vi.fn(), + setPromptStatus: opts.setPromptStatus ?? vi.fn(), setRestoreSessionId: opts.setRestoreSessionId ?? vi.fn(), setRestoreWorkspaceCwd: opts.setRestoreWorkspaceCwd ?? vi.fn(), setRestoreMode: vi.fn(), @@ -1168,6 +1419,8 @@ function createMockSession( clientId, client: { createOrAttachSession: vi.fn(), + branchSession: vi.fn(), + detachSession: vi.fn(async () => undefined), setSessionApprovalMode: vi.fn(async () => ({ sessionId, mode: 'default', @@ -1181,6 +1434,7 @@ function createMockSession( context: vi.fn(async () => contextStatus(sessionId)), detach: vi.fn(async () => undefined), setModel: vi.fn(async () => ({})), + shellCommand: vi.fn(async () => ({})), submitPrompt: vi.fn(async () => ({ promptId: 'prompt-1' })), supportedCommands: vi.fn(async () => supportedCommandsStatus(sessionId)), tasks: vi.fn(async () => ({ v: 1 as const, sessionId, tasks: [] })), diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 598b3925d1d..718f03a1b51 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -83,7 +83,9 @@ export function resolveSessionRestoreTimeouts( function clearPendingLoadTimeout(load: PendingSessionLoad): void { if (load.timeout !== undefined) clearTimeout(load.timeout); } - +export function normalizeWorkspaceIdentity(value: string | undefined): string { + return value ? value.replace(/\\/g, '/').replace(/\/+$/, '') || '/' : ''; +} export interface CreateDaemonSessionActionsArgs { store: DaemonTranscriptStore; sessionRef: RefBox; @@ -117,8 +119,21 @@ export interface CreateDaemonSessionActionsArgs { setAttachSessionNonce: Dispatch>; setNewSessionNonce: Dispatch>; clearLiveJournalRepair?: () => void; + beginCrossSessionTransition?: ( + request: { + sessionId: string; + mode: 'load' | 'resume'; + workspaceCwd?: string; + origin: 'action' | 'controlled'; + }, + startLegacy: () => Promise, + ) => Promise; + cancelCrossSessionTransition?: (reason: string) => void; + isCrossSessionTransitionPending?: () => boolean; + isSourceBoundOperationInFlight?: () => boolean; + setSourceBoundOperationInFlight?: (inFlight: boolean) => void; + getTransitionOrigin?: () => 'action' | 'controlled'; } - export function getConnectionAfterSessionClear( current: DaemonConnectionState, clearedSessionId: string | undefined, @@ -154,7 +169,6 @@ export function getConnectionAfterSessionClear( missingSession: false, }; } - export function createDaemonSessionActions({ store, sessionRef, @@ -182,12 +196,61 @@ export function createDaemonSessionActions({ setAttachSessionNonce, setNewSessionNonce, clearLiveJournalRepair = () => undefined, + beginCrossSessionTransition, + cancelCrossSessionTransition = () => undefined, + isCrossSessionTransitionPending = () => false, + isSourceBoundOperationInFlight = () => false, + setSourceBoundOperationInFlight = () => undefined, + getTransitionOrigin = () => 'action', }: CreateDaemonSessionActionsArgs): DaemonSessionActions { const silentHardFailureNoticeKeys = new Set(); + let noticeOwner = sessionRef.current; + + function requireStableSession(): void { + if (isCrossSessionTransitionPending()) { + throw new DOMException( + 'A session switch is still preparing', + 'InvalidStateError', + ); + } + if (isSourceBoundOperationInFlight()) { + throw new DOMException( + 'Another session operation is still in progress', + 'InvalidStateError', + ); + } + } + + const isCurrentLogicalSession = (session: DaemonSessionClient) => { + const current = sessionRef.current; + return ( + current?.sessionId === session.sessionId && + normalizeWorkspaceIdentity(current.workspaceCwd) === + normalizeWorkspaceIdentity(session.workspaceCwd) + ); + }; + + const ignoreStaleNotice: AddDaemonSessionNotice = (notice) => ({ + ...notice, + id: notice.id ?? 'stale-session-notice', + createdAt: notice.createdAt ?? 0, + }); + const noticeForSession = (session: DaemonSessionClient) => { + if (sessionRef.current !== session) return ignoreStaleNotice; + if (noticeOwner !== session) silentHardFailureNoticeKeys.clear(); + noticeOwner = session; + return addNotice; + }; + const noticeForLogicalSession = (session: DaemonSessionClient) => + isCurrentLogicalSession(session) ? addNotice : ignoreStaleNotice; + const shouldSettlePromptForSession = (session: DaemonSessionClient) => + sessionRef.current === session || + (isCurrentLogicalSession(session) && !hasSessionActivePrompt()); function clearActiveSessionState() { clearLiveJournalRepair(); silentHardFailureNoticeKeys.clear(); + noticeOwner = undefined; for (const [, active] of activePromptsRef.current) { active.controller.abort(); } @@ -289,7 +352,7 @@ export function createDaemonSessionActions({ return loadPromise; } - function startSessionSwitch( + function startLegacySessionSwitch( sessionId: string, mode: 'load' | 'resume', workspaceCwd?: string, @@ -381,8 +444,69 @@ export function createDaemonSessionActions({ }); } + function startSessionSwitch( + sessionId: string, + mode: 'load' | 'resume', + workspaceCwd?: string, + signal?: AbortSignal, + replaySource?: PendingSessionLoad['replaySource'], + ): Promise { + if (signal?.aborted) { + return Promise.reject( + new DOMException('Session load cancelled', 'AbortError'), + ); + } + const origin = getTransitionOrigin(); + const startLegacy = () => + startLegacySessionSwitch( + sessionId, + mode, + workspaceCwd, + signal, + replaySource, + ); + const current = sessionRef.current; + const targetWorkspace = workspaceCwd ?? getConnection().workspaceCwd; + const crossLogicalTarget = + current !== undefined && + (current.sessionId !== sessionId || + normalizeWorkspaceIdentity(current.workspaceCwd) !== + normalizeWorkspaceIdentity(targetWorkspace)); + if ( + !crossLogicalTarget && + replaySource === undefined && + isCrossSessionTransitionPending() + ) { + return Promise.reject( + new DOMException( + 'A session switch is still preparing', + 'InvalidStateError', + ), + ); + } + if ( + !crossLogicalTarget || + replaySource !== undefined || + !beginCrossSessionTransition + ) { + return startLegacy(); + } + return beginCrossSessionTransition( + { + sessionId, + mode, + ...(targetWorkspace !== undefined + ? { workspaceCwd: targetWorkspace } + : {}), + origin, + }, + startLegacy, + ); + } + return { async sendPrompt(text, options) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -451,7 +575,7 @@ export function createDaemonSessionActions({ ); } catch (error) { if (isAbortError(error)) { - if (sessionRef.current?.sessionId === sessionId) { + if (shouldSettlePromptForSession(session)) { store.dispatch({ type: 'assistant.done', reason: 'cancelled' }); } return { stopReason: 'cancelled' }; @@ -459,11 +583,11 @@ export function createDaemonSessionActions({ if (isDaemonTurnError(error)) { throw error; } - if (sessionRef.current?.sessionId === sessionId) { + if (shouldSettlePromptForSession(session)) { store.dispatch({ type: 'assistant.done', reason: 'error' }); } throw dispatchActionError( - addNotice, + noticeForLogicalSession(session), 'Prompt failed', error, 'send_prompt', @@ -473,16 +597,14 @@ export function createDaemonSessionActions({ if (active?.controller === ctrl) { activePromptsRef.current.delete(sessionId); } - if ( - sessionRef.current?.sessionId === sessionId && - !hasSessionActivePrompt() - ) { + if (isCurrentLogicalSession(session) && !hasSessionActivePrompt()) { setPromptStatus('idle'); } } }, async submitPrompt(text, options) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -532,7 +654,7 @@ export function createDaemonSessionActions({ '[submitPrompt] removePendingPrompt failed after abort', err, ); - addNotice({ + noticeForSession(session)({ severity: 'error', category: 'user_action', operation: 'send_prompt', @@ -570,7 +692,7 @@ export function createDaemonSessionActions({ await withActionTimeout(session.cancel(), 'Cancel timed out'); } catch (error) { throw dispatchActionError( - addNotice, + noticeForLogicalSession(session), 'Cancel failed', error, 'cancel_prompt', @@ -583,16 +705,14 @@ export function createDaemonSessionActions({ ) { activePromptsRef.current.delete(session.sessionId); } - if ( - sessionRef.current?.sessionId === session.sessionId && - !hasSessionActivePrompt() - ) { + if (isCurrentLogicalSession(session) && !hasSessionActivePrompt()) { setPromptStatus('idle'); } } }, async setModel(modelId) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -610,7 +730,7 @@ export function createDaemonSessionActions({ return result; } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Set model failed', error, 'switch_model', @@ -619,6 +739,7 @@ export function createDaemonSessionActions({ }, async setApprovalMode(mode, opts) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -642,7 +763,7 @@ export function createDaemonSessionActions({ return result; } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Set approval mode failed', error, 'set_approval_mode', @@ -664,7 +785,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Permission response failed', error, 'submit_permission', @@ -696,7 +817,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Permission response failed', error, 'submit_permission', @@ -720,7 +841,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'List sessions failed', error, 'list_sessions', @@ -733,6 +854,7 @@ export function createDaemonSessionActions({ }, async reloadSession(signal, options) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -759,6 +881,7 @@ export function createDaemonSessionActions({ worktree?: { slug?: string }; branch?: { name: string }; }) { + requireStableSession(); try { manualSessionClearRef.current = false; // Fold the initial approval mode into the create request so the daemon @@ -844,6 +967,7 @@ export function createDaemonSessionActions({ }, async attachSession() { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -856,6 +980,7 @@ export function createDaemonSessionActions({ }, async clearSession() { + cancelCrossSessionTransition('Session transition cancelled by clear'); const session = sessionRef.current; manualSessionClearRef.current = true; clearActiveSessionState(); @@ -873,6 +998,9 @@ export function createDaemonSessionActions({ }, async newSession() { + cancelCrossSessionTransition( + 'Session transition cancelled by new session', + ); manualSessionClearRef.current = false; clearActiveSessionState(); setConnection((current) => ({ @@ -886,6 +1014,11 @@ export function createDaemonSessionActions({ async releaseSession(sessionId) { try { + if (sessionRef.current?.sessionId === sessionId) { + cancelCrossSessionTransition( + 'Session transition cancelled by release', + ); + } const session = requireSessionForAction( addNotice, sessionRef.current, @@ -907,6 +1040,7 @@ export function createDaemonSessionActions({ }, async closeSession() { + cancelCrossSessionTransition('Session transition cancelled by close'); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -917,7 +1051,7 @@ export function createDaemonSessionActions({ await withActionTimeout(session.close(), 'Close session timed out'); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Close session failed', error, 'close_session', @@ -947,8 +1081,9 @@ export function createDaemonSessionActions({ })); } } catch (error) { + if (sessionRef.current !== session) return; throw dispatchActionError( - addNotice, + noticeForSession(session), 'Refresh commands failed', error, 'refresh_commands', @@ -981,7 +1116,7 @@ export function createDaemonSessionActions({ return context; } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Load context failed', error, 'load_context', @@ -1003,7 +1138,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Load context usage failed', error, 'load_context_usage', @@ -1012,6 +1147,7 @@ export function createDaemonSessionActions({ }, async renameSession(displayName) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1025,7 +1161,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Rename session failed', error, 'rename_session', @@ -1034,6 +1170,7 @@ export function createDaemonSessionActions({ }, async recapSession(): Promise { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1047,7 +1184,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Recap session failed', error, 'recap_session', @@ -1059,6 +1196,7 @@ export function createDaemonSessionActions({ prompt: string, opts?: { signal?: AbortSignal }, ): AsyncGenerator { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1084,7 +1222,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Load rewind snapshots failed', error, 'rewind_snapshots', @@ -1096,6 +1234,7 @@ export function createDaemonSessionActions({ promptId: string, opts?: { rewindFiles?: boolean }, ): Promise { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1109,7 +1248,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Rewind session failed', error, 'rewind_session', @@ -1121,6 +1260,7 @@ export function createDaemonSessionActions({ question: string, opts?: { signal?: AbortSignal }, ): Promise { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1137,7 +1277,7 @@ export function createDaemonSessionActions({ throw error; } throw dispatchActionError( - addNotice, + noticeForSession(session), 'Side question failed', error, 'btw_session', @@ -1149,6 +1289,7 @@ export function createDaemonSessionActions({ message: string, opts?: { signal?: AbortSignal; messageId?: string }, ): Promise { + if (isCrossSessionTransitionPending()) return { accepted: false }; // Calls without an id are the old-daemon compatibility path and fall back // locally. With a stable id, transport failure is ambiguous (the POST may // already have committed), so let the caller reconcile instead of @@ -1241,6 +1382,7 @@ export function createDaemonSessionActions({ }, async sendShellCommand(command: string) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1255,7 +1397,7 @@ export function createDaemonSessionActions({ return await session.shellCommand(command, ctrl.signal); } catch (error) { throw dispatchActionError( - addNotice, + noticeForLogicalSession(session), 'Shell command failed', error, 'send_shell_command', @@ -1264,10 +1406,7 @@ export function createDaemonSessionActions({ if (activePromptsRef.current.get(shellKey)?.controller === ctrl) { activePromptsRef.current.delete(shellKey); } - if ( - sessionRef.current?.sessionId === session.sessionId && - !hasSessionActivePrompt() - ) { + if (isCurrentLogicalSession(session) && !hasSessionActivePrompt()) { setPromptStatus('idle'); } } @@ -1289,7 +1428,7 @@ export function createDaemonSessionActions({ throw error; } throw dispatchActionError( - addNotice, + noticeForSession(session), 'Get tasks failed', error, 'load_tasks', @@ -1317,7 +1456,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Cancel task failed', error, 'cancel_task', @@ -1326,6 +1465,7 @@ export function createDaemonSessionActions({ }, async clearGoal() { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1339,7 +1479,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Clear goal failed', error, 'clear_goal', @@ -1358,7 +1498,7 @@ export function createDaemonSessionActions({ return await withActionTimeout(session.stats(), 'Load stats timed out'); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Load stats failed', error, 'load_stats', @@ -1389,7 +1529,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Global permission response failed', error, 'submit_permission', @@ -1398,6 +1538,7 @@ export function createDaemonSessionActions({ }, async branchSession(name?: string) { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1405,17 +1546,35 @@ export function createDaemonSessionActions({ 'branch_session', ); try { + setSourceBoundOperationInFlight(true); + const branchRequest = session.client.branchSession( + session.sessionId, + { name }, + session.clientId, + ); + void branchRequest.then( + () => setSourceBoundOperationInFlight(false), + () => setSourceBoundOperationInFlight(false), + ); const result = await withActionTimeout( - session.client.branchSession( - session.sessionId, - { name }, - session.clientId, - ), + branchRequest, 'Branch session timed out', ); + if (!isCurrentLogicalSession(session)) { + void session.client + .detachSession(result.sessionId, result.clientId) + .catch(() => undefined); + return { + sessionId: result.sessionId, + displayName: result.displayName, + }; + } persistStableClientId(result.clientId, result.sessionId); void startSessionSwitch(result.sessionId, 'load').catch( (switchError: unknown) => { + void session.client + .detachSession(result.sessionId, result.clientId) + .catch(() => undefined); if (isAbortError(switchError)) return; dispatchActionError( addNotice, @@ -1440,6 +1599,7 @@ export function createDaemonSessionActions({ }, async forkSession(directive: string): Promise { + requireStableSession(); const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1453,7 +1613,7 @@ export function createDaemonSessionActions({ ); } catch (error) { throw dispatchActionError( - addNotice, + noticeForSession(session), 'Fork session failed', error, 'fork_session', diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index e14324bda03..d1f5bce1a58 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -51,6 +51,25 @@ export type DaemonConnectionStatus = | 'disconnected' | 'error'; +export interface DaemonSessionTransition { + phase: 'queued' | 'preparing' | 'failed'; + operation: 'load' | 'resume'; + origin: 'action' | 'controlled'; + targetSessionId: string; + targetWorkspaceCwd?: string; + targetClientId?: string; + error?: { + message: string; + code?: string; + status?: number; + }; +} +export interface DaemonSessionOwnerSnapshot { + isCurrent(): boolean; +} +export interface DaemonSessionOwnerGuard { + capture(): DaemonSessionOwnerSnapshot; +} export interface DaemonConnectionState { status: DaemonConnectionStatus; sessionId?: string; @@ -94,6 +113,7 @@ export interface DaemonConnectionState { errorStatus?: number; /** True only when the server confirmed the current session is missing. */ missingSession?: boolean; + sessionTransition?: DaemonSessionTransition; } export interface DaemonTokenUsage { @@ -152,6 +172,10 @@ export interface DaemonSessionProviderProps { /** Warning shown when session context metadata cannot be loaded. */ context?: string; }; + onSessionTransitionCommit?: (target: { + sessionId: string; + workspaceCwd?: string; + }) => void; /** React children rendered inside the daemon session contexts. */ children: ReactNode; }