diff --git a/docs/design/standalone-daemon-sessions.md b/docs/design/standalone-daemon-sessions.md index 336d111be9e..93c4a012483 100644 --- a/docs/design/standalone-daemon-sessions.md +++ b/docs/design/standalone-daemon-sessions.md @@ -1213,32 +1213,59 @@ Estimated size: 300-500 production lines and 450-800 test lines. Exit criterion: consumers use the complete lifecycle without constructing routes or supplying internal cwd. -### PR5: Explicit WebUI context - -Suggested title: `feat(webui): Add explicit daemon session contexts` - -Dependency: PR4. [PR #8882](https://github.com/QwenLM/qwen-code/pull/8882) is -merged; re-audit its final API and extend its transaction rather than -duplicating it. - -- Add `standalone | workspace { cwd } | live` to connection and transition - state. -- Classify from persisted source plus validated ownership, never cwd/runtime - kind alone. -- Atomically commit or roll back client, transcript, internal cwd, product - context, warnings, and deferred intent. -- Accept legacy `workspaceCwd` only at the workspace compatibility boundary, - normalize it immediately, and reject conflicts. It never selects standalone. -- Add directory-recreated/missing/compromised and outcome-unknown notice state. - -Verification covers all #8882 failure and supersession cases plus cross-context -switching, capability absence, legacy source, outcome recovery, warning -rollback, and no-primary-fallback. - -Estimated size: 350-650 production lines and 650-1,100 test lines. - -Exit criterion: WebUI represents and switches all contexts explicitly while -existing visible WebShell behavior remains unchanged. +### PR5: Explicit WebShell session context + +Suggested title: `feat(web-shell): Add explicit daemon session contexts` + +Dependency: PR4. PR5 targets the current `main` ownership boundary: WebShell +continues to consume the daemon React provider from `@qwen-code/webui`, so the +provider changes land there and WebShell-facing types remain exported through +the existing `daemon-react-sdk` entry. The later WebShell cutover can carry the +same files by rename; it is not a prerequisite. + +- Add `standalone | workspace { cwd } | live` to provider props, connection + state, and transition state. Use a distinct `sessionContext` name because + `connection.context` already stores model context-window status. +- Classify from an explicit requested context plus the authoritative restore + path. Standalone uses the dedicated capability-gated SDK methods. Workspace + uses its exact ordinary runtime cwd. Live resolves exactly one trusted + capability-advertised Live runtime and then relies on the daemon's persisted + source and ownership validation. Source strings or cwd alone never select a + product context. +- Follow the loading-skeleton switching model restored by + [PR #9129](https://github.com/QwenLM/qwen-code/pull/9129): publish the target + context, clear the old transcript, and keep the failed target visible with an + explicit error. Do not restore the transaction or roll back to the previous + conversation. A generation guard prevents a superseded completion from + publishing its client, transcript, warnings, or context. +- Accept legacy `workspaceCwd` only at one compatibility boundary, normalize it + immediately to `{ kind: 'workspace', cwd }`, and reject conflicts with an + explicit context. It never selects standalone or Live. Existing callers that + provide neither field retain the current primary-workspace behavior. +- Keep daemon-internal routing cwd private. `connection.workspaceCwd` remains a + product workspace only and is absent for standalone and Live sessions. + Standalone working-directory state and outcome-unknown recovery remain in + target-scoped standalone connection state. +- Skip workspace providers, Git, preheat, and workspace event invalidation for + standalone and Live contexts. Session-scoped commands, model context, Goal, + transcript, prompt, and permission behavior remain shared. +- Standalone creation awaits the SDK operation directly so the SDK can complete + its single exact recovery lookup. It is never wrapped in the provider's + shorter generic action timeout and is never retried automatically. + +Verification covers normalization conflicts, exact workspace/standalone/Live +dispatch, cross-context switching, capability absence, ambiguous Live runtime, +legacy callers, reconnect and reload, outcome recovery, target-scoped directory +warnings, supersession, and no-primary-fallback. + +Audited implementation footprint: approximately 910 added production lines and +1,250 added test lines. Most production churn is the explicit routing, +transition, reconnect, and target-scoped error handling inside the existing +provider rather than new abstraction surface. + +Exit criterion: the daemon React provider represents and switches all contexts +explicitly without changing visible WebShell entry points. PR6 owns global New +Chat, Recents, lifecycle controls, deep links, and project-control visibility. ### PR6: WebShell product UI @@ -1278,15 +1305,15 @@ flowchart LR PR1 --> PR2["PR2 standalone core"] PR2 --> PR3["PR3 complete daemon API"] PR3 --> PR4["PR4 SDK"] - PR4 --> PR5["PR5 WebUI context"] - T["PR #8882 transactional switching"] --> PR5 + PR4 --> PR5["PR5 WebShell session context"] + S["PR #9129 loading-skeleton switching"] --> PR5 PR5 --> PR6["PR6 WebShell"] ``` -PR0 through PR6 are the required feature sequence. PR5 builds on the final API -merged by PR #8882. PR #8874 (workspace uploads) and PR #8817 (fork/move -foundations) are follow-up dependencies rather than MVP blockers. No capability -is advertised before PR3. +PR0 through PR6 are the required feature sequence. PR5 builds on the +loading-skeleton switching model restored by PR #9129. PR #8874 (workspace +uploads) and PR #8817 (fork/move foundations) are follow-up dependencies rather +than MVP blockers. No capability is advertised before PR3. Expected total implementation size is approximately 3,800-6,170 production lines plus 7,600-11,800 test lines. The companion document is excluded from diff --git a/docs/plans/2026-08-28-standalone-pr5-webshell-context.md b/docs/plans/2026-08-28-standalone-pr5-webshell-context.md new file mode 100644 index 00000000000..027c195c4ce --- /dev/null +++ b/docs/plans/2026-08-28-standalone-pr5-webshell-context.md @@ -0,0 +1,86 @@ +# Standalone PR5 WebShell Session Context Plan + +## Goal + +Make the daemon React provider represent and switch workspace, standalone, and Live sessions explicitly while keeping current WebShell entry points unchanged. The implementation starts from `main`, where WebShell consumes the provider through `@qwen-code/webui/daemon-react-sdk`. It does not copy the in-flight WebShell cutover; a later rename can carry the provider changes unchanged. + +## Product Context Contract + +```ts +type DaemonProductSessionContext = + | { kind: 'workspace'; cwd: string } + | { kind: 'standalone' } + | { kind: 'live' }; +``` + +`sessionContext` is the product and routing authority. `connection.context` remains the existing model context-window snapshot. `connection.workspaceCwd` is set only for a product workspace; the internal Conversations runtime cwd is never exposed as a project. + +Legacy callers may continue supplying `workspaceCwd`. The provider converts it once to a workspace context. An explicit workspace context must match a simultaneously supplied legacy cwd after path normalization. Standalone and Live contexts reject a supplied legacy cwd. If neither value is supplied, existing primary-workspace behavior is preserved. + +## Restore and Create Dispatch + +| Product context | Create | Load and resume | Ownership proof | +| --------------- | -------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| Workspace | Existing generic create with exact cwd | Existing generic load/resume with exact cwd | Exact ordinary runtime selected by the caller/provider | +| Standalone | `DaemonSessionClient.createStandalone` | `loadStandalone` / `resumeStandalone` | SDK runtime validation of the dedicated standalone response | +| Live | Not created by this provider | Generic load/resume against the one trusted `kind: 'live'` runtime | Capability runtime identity plus daemon-side persisted-source validation | + +An explicit context never falls back to the primary workspace. A missing standalone capability, a missing/untrusted/ambiguous Live runtime, or a context/cwd conflict fails before a session request is sent. + +## Switching Semantics + +The provider retains the post-#9129 loading-skeleton model: + +1. Capture a new transition generation and publish the target session id and context. +2. Detach the previous client, clear the old transcript, and show the target loading state. +3. Commit the restored client, replay, standalone working-directory state, and warnings only if the transition is still current. +4. On failure, keep the target id/context visible with the structured error. Do not restore the previous conversation. +5. Detach a stale successful client and discard its replay, warnings, and recovery state. + +Reload, reconnect, live-journal repair, and invalid-client reattachment reuse the stored context. They never reconstruct context from an absent cwd. + +## Standalone State + +Successful standalone create/load stores the SDK-provided `projectlessOutputDirectory` and `workingDirectory` under a standalone-specific connection field. A `recreated` warning belongs only to that target and is discarded if another transition supersedes it. + +`DaemonStandaloneCreationOutcomeUnknownError` is rethrown intact. When the create owns an empty connection, the generated UUID and exact-lookup recovery result are also recorded in connection state. A detached create beside an active session leaves that active connection untouched and carries recovery only on the structured error. The provider does not retry create. It does not wrap standalone create in the generic 30-second action timeout because that could reject before the SDK's required exact lookup completes. + +Standalone directory error codes are copied from structured daemon error bodies into connection state so PR6 can present repair or terminal guidance without parsing strings. + +## Workspace Isolation + +For standalone and Live sessions, the provider skips session-less workspace providers, skills, ACP preheat, Git status, and workspace event invalidation. Session-scoped supported commands, context/model status, Goal state, transcript replay, prompts, permissions, and heartbeat remain available. + +The current visible WebShell has no standalone entry point in PR5. PR6 must gate App-level workspace features before wiring Global New Chat and Recents to the new context. PR5 nevertheless exposes enough typed state for that gating without interpreting the internal runtime cwd as a workspace. + +## Compatibility and Migration + +- Existing Provider props and action calls remain valid. +- Workspace behavior and primary fallback remain unchanged only for callers without an explicit context. +- The public daemon React SDK exports the context and standalone state types. +- No new daemon route or SDK validator is added. +- The later WebShell provider cutover moves these files without architectural changes. +- Before publishing a WebShell package that directly requires the standalone SDK methods, align its SDK peer minimum with the first released SDK version containing PR4. + +## Verification + +Focused tests cover: + +- context normalization and conflict rejection; +- exact route selection for workspace, standalone, and Live; +- missing standalone capability and zero/multiple/untrusted Live runtime failures with no fallback request; +- workspace to standalone to workspace/Live switching; +- rapid supersession, stale-client detach, and target-only replay/warnings; +- loading-skeleton failure semantics with no rollback; +- reload and reconnect using the stored context; +- outcome-unknown recovery states without create retry or outer timeout masking; +- recreated-directory state and structured directory error codes; +- no workspace providers, skills, Git, preheat, or event invalidation for non-workspace contexts; +- unchanged legacy workspace callers and controlled Provider transitions; +- public type exports and browser bundles. + +Run the focused WebUI provider/action tests, WebUI build/typecheck/lint, WebShell provider integration tests, WebShell build/typecheck/lint/format, root build and typecheck, and the SDK/WebShell public-surface and browser-bundle checks. + +## Scope Boundary + +PR5 does not add Global New Chat, Recents, lifecycle menus, archive/delete/repair controls, deep links, standalone uploads, or project-control hiding. Those visible product flows remain PR6. It does not change daemon lifecycle behavior or recreate the reverted transactional session-switch coordinator. diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index 63734f57581..4d1e8a59687 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -197,6 +197,10 @@ export type { DaemonTokenUsage, /** Model descriptor: id, display label, context window size. */ DaemonModelInfo, + /** Explicit workspace, standalone, or Live product session context. */ + DaemonProductSessionContext, + /** Standalone working-directory and create-recovery state. */ + DaemonStandaloneConnectionState, /** Classified notice category for host-owned UI routing. */ DaemonNoticeCategory, /** Fine-grained operation associated with a session notice. */ diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts index c1600c74f67..c46ac83201f 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/webui/src/daemon/index.ts @@ -40,6 +40,8 @@ export type { DaemonConnectionState, DaemonConnectionStatus, DaemonModelInfo, + DaemonProductSessionContext, + DaemonStandaloneConnectionState, DaemonNoticeCategory, DaemonNoticeOperation, DaemonNoticeSeverity, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 572c9b4e536..f4721f6da76 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -21,7 +21,9 @@ import type { PromptResult, } from '@qwen-code/sdk/daemon'; import { + DaemonCapabilityMissingError, DaemonHttpError, + DaemonStandaloneCreationOutcomeUnknownError, estimateDaemonTranscriptBlockBytes, UNRECOGNIZED_DIAGNOSTICS_LIMIT, } from '@qwen-code/sdk/daemon'; @@ -67,6 +69,7 @@ interface MockSession { replayPartial?: boolean; replayError?: string; eventEpoch?: string; + session?: Record; client?: MockClient; lastEventId?: number; setLastEventId: (lastEventId: number | undefined) => void; @@ -131,6 +134,7 @@ interface MockClient { capabilities: () => Promise; workspaceProviders: () => Promise; listWorkspaceSessions: () => Promise; + listStandaloneSessions: () => Promise; closeSession: () => Promise; setSessionApprovalMode: () => Promise<{ mode: string }>; workspaceMcp: () => Promise; @@ -181,9 +185,11 @@ interface MockClient { const sdkMocks = vi.hoisted(() => { const sessions: MockSession[] = []; + const daemonClientOptions: unknown[] = []; const capabilities = vi.fn(); const workspaceProviders = vi.fn(); const listWorkspaceSessions = vi.fn(); + const listStandaloneSessions = vi.fn(); const closeSession = vi.fn(); const setSessionApprovalMode = vi.fn(); const workspaceMcp = vi.fn(); @@ -210,7 +216,9 @@ const sdkMocks = vi.hoisted(() => { const getSessionTranscriptPage = vi.fn(); class MockDaemonClient { - constructor(_opts: unknown) {} + constructor(opts: unknown) { + daemonClientOptions.push(opts); + } createOrAttachSession = vi.fn((req: unknown) => MockDaemonSessionClient.createOrAttach(this, req), @@ -218,6 +226,7 @@ const sdkMocks = vi.hoisted(() => { capabilities = capabilities; workspaceProviders = workspaceProviders; listWorkspaceSessions = listWorkspaceSessions; + listStandaloneSessions = listStandaloneSessions; closeSession = closeSession; setSessionApprovalMode = setSessionApprovalMode; workspaceMcp = workspaceMcp; @@ -273,13 +282,35 @@ const sdkMocks = vi.hoisted(() => { _clientId?: string, ): Promise => takeSession(client), ); + static createStandalone = vi.fn( + async (client: unknown, _opts?: unknown): Promise => + takeSession(client), + ); + static loadStandalone = vi.fn( + async ( + client: unknown, + _sessionId: string, + _opts?: unknown, + _clientId?: string, + ): Promise => takeSession(client), + ); + static resumeStandalone = vi.fn( + async ( + client: unknown, + _sessionId: string, + _opts?: unknown, + _clientId?: string, + ): Promise => takeSession(client), + ); } return { sessions, + daemonClientOptions, capabilities, workspaceProviders, workspaceSkills, + listStandaloneSessions, workspaceAcpStatus, workspaceAcpPreheat, workspaceGit, @@ -294,6 +325,7 @@ const sdkMocks = vi.hoisted(() => { getSessionTranscriptPage, reset() { sessions.length = 0; + daemonClientOptions.length = 0; capabilities.mockReset(); capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', @@ -308,6 +340,8 @@ const sdkMocks = vi.hoisted(() => { }); listWorkspaceSessions.mockReset(); listWorkspaceSessions.mockResolvedValue([]); + listStandaloneSessions.mockReset(); + listStandaloneSessions.mockResolvedValue([]); closeSession.mockReset(); closeSession.mockResolvedValue(undefined); setSessionApprovalMode.mockReset(); @@ -412,6 +446,18 @@ const sdkMocks = vi.hoisted(() => { async (client: unknown, _sessionId: string): Promise => takeSession(client), ); + MockDaemonSessionClient.createStandalone.mockReset(); + MockDaemonSessionClient.createStandalone.mockImplementation( + async (client: unknown): Promise => takeSession(client), + ); + MockDaemonSessionClient.loadStandalone.mockReset(); + MockDaemonSessionClient.loadStandalone.mockImplementation( + async (client: unknown): Promise => takeSession(client), + ); + MockDaemonSessionClient.resumeStandalone.mockReset(); + MockDaemonSessionClient.resumeStandalone.mockImplementation( + async (client: unknown): Promise => takeSession(client), + ); }, }; }); @@ -452,79 +498,1261 @@ describe('DaemonSessionProvider', () => { it('exposes idle connection state without auto connect', async () => { let connection: DaemonConnectionState | undefined; - let blocks: readonly DaemonTranscriptBlock[] | undefined; + let blocks: readonly DaemonTranscriptBlock[] | undefined; + + function Harness() { + connection = useDaemonConnection(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(); + + expect(connection).toEqual({ status: 'idle' }); + expect(blocks).toEqual([]); + }); + + it('does not rerender streaming state consumers for equivalent transcript updates', async () => { + let store: DaemonTranscriptStore | undefined; + let renderCount = 0; + + function Harness() { + store = useDaemonTranscriptStore(); + useDaemonStreamingState(); + renderCount += 1; + return null; + } + + await renderWithProvider(); + const initialRenderCount = renderCount; + + act(() => { + store?.appendLocalUserMessage('first'); + store?.appendLocalUserMessage('second'); + }); + + expect(renderCount).toBe(initialRenderCount); + }); + + it('keeps capabilities handshake failures out of the transcript', async () => { + sdkMocks.capabilities.mockRejectedValue( + Object.assign(new Error('GET /capabilities: HTTP 400'), { status: 400 }), + ); + let connection: DaemonConnectionState | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + connection = useDaemonConnection(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + + await act(async () => { + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'error', + error: 'GET /capabilities: HTTP 400', + }); + expect(blocks).toEqual([]); + }); + + it('connects without creating a session by default', async () => { + sdkMocks.workspaceProviders.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + approvalMode: 'yolo', + providers: [], + }); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + }); + + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'connected', + workspaceCwd: '/mock-workspace', + currentMode: 'yolo', + gitBranch: 'main', + }); + expect(connection).not.toHaveProperty('sessionId'); + }); + + it('loads standalone sessions without workspace fallback or metadata calls', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + workspaces: [ + { + id: 'primary', + cwd: '/primary', + primary: true, + trusted: true, + }, + ], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-1', + workspaceCwd: '/private/standalone-1', + session: { + sessionId: 'standalone-1', + workspaceCwd: '/private/standalone-1', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-1', + workingDirectory: { state: 'ready' }, + }, + }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-1', + sessionContext: { kind: 'standalone' }, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenCalledWith( + expect.anything(), + 'standalone-1', + { timeoutMs: 70_000 }, + expect.any(String), + ); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceProviders).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceSkills).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceByCwd).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + projectlessOutputDirectory: '/output/standalone-1', + workingDirectory: { state: 'ready' }, + }, + }); + + await act(async () => { + await actions?.listSessions(); + }); + expect(sdkMocks.listStandaloneSessions).toHaveBeenCalledOnce(); + }); + + it('resumes standalone sessions through the dedicated route', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-resumed', + workspaceCwd: '/private/standalone-resumed', + session: { + sessionId: 'standalone-resumed', + workspaceCwd: '/private/standalone-resumed', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-resumed', + workingDirectory: { state: 'ready' }, + }, + }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + sessionContext: { kind: 'standalone' }, + }); + await act(async () => flushPromises()); + let resume!: Promise; + act(() => { + resume = requireActions(actions).resumeSession('standalone-resumed', { + sessionContext: { kind: 'standalone' }, + }); + }); + await act(async () => flushPromises()); + await expect(resume).resolves.toBeUndefined(); + + expect( + sdkMocks.MockDaemonSessionClient.resumeStandalone, + ).toHaveBeenCalledWith( + expect.anything(), + 'standalone-resumed', + { timeoutMs: 70_000 }, + expect.any(String), + ); + expect(sdkMocks.MockDaemonSessionClient.resume).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'standalone-resumed', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + }); + }); + + it('keeps standalone working-directory failures target-scoped', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + sdkMocks.MockDaemonSessionClient.loadStandalone.mockRejectedValueOnce( + new DaemonHttpError( + 409, + { code: 'working_directory_compromised', retryable: false }, + 'working directory compromised', + ), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-broken', + sessionContext: { kind: 'standalone' }, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('error')); + }); + + expect(connection).toMatchObject({ + sessionId: 'standalone-broken', + sessionContext: { kind: 'standalone' }, + standaloneSession: { + errorCode: 'working_directory_compromised', + }, + }); + expect(connection?.workspaceCwd).toBeUndefined(); + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenCalledOnce(); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + }); + + it('loads Live sessions through the unique trusted runtime without exposing its cwd', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['multi_workspace_sessions'], + workspaces: [ + { + id: 'primary', + cwd: '/primary', + primary: true, + trusted: true, + }, + { + id: 'live', + cwd: '/conversations', + kind: 'live', + primary: false, + trusted: true, + }, + ], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'live-1', + workspaceCwd: '/conversations', + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'live-1', + sessionContext: { kind: 'live' }, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledWith( + expect.anything(), + 'live-1', + expect.objectContaining({ workspaceCwd: '/conversations' }), + expect.anything(), + ); + expect(sdkMocks.workspaceProviders).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceSkills).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceByCwd).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + sessionContext: { kind: 'live' }, + workspaceCwd: undefined, + }); + }); + + it('fails a Live load once when runtime ownership is ambiguous', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['multi_workspace_sessions'], + workspaces: [ + { + id: 'live-a', + cwd: '/conversations/a', + kind: 'live', + primary: false, + trusted: true, + }, + { + id: 'live-b', + cwd: '/conversations/b', + kind: 'live', + primary: false, + trusted: true, + }, + ], + }); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, + sessionId: 'live-ambiguous', + sessionContext: { kind: 'live' }, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('error')); + await flushPromises(); + }); + await act(async () => { + await wait(20); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + error: 'Daemon advertises multiple Live session runtimes', + sessionContext: { kind: 'live' }, + }); + expect(sdkMocks.capabilities).toHaveBeenCalledOnce(); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).not.toHaveBeenCalled(); + }); + + it('rejects a non-workspace context combined with workspaceCwd before connecting', async () => { + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + workspaceCwd: '/primary', + sessionContext: { kind: 'standalone' }, + }); + await act(async () => { + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'error', + error: 'standalone session context cannot include workspaceCwd', + }); + expect(sdkMocks.capabilities).not.toHaveBeenCalled(); + + await act(async () => { + await expect(requireActions(actions).createSession()).rejects.toThrow( + 'standalone session context cannot include workspaceCwd', + ); + }); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).not.toHaveBeenCalled(); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'error', + error: 'standalone session context cannot include workspaceCwd', + }); + }); + + it('does not start a controlled load for conflicting context props', async () => { + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-conflict', + workspaceCwd: '/primary', + sessionContext: { kind: 'standalone' }, + }); + await act(async () => flushPromises()); + + expect(connection).toMatchObject({ + status: 'error', + error: 'standalone session context cannot include workspaceCwd', + }); + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).not.toHaveBeenCalled(); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + }); + + it('keeps a connected session attached when controlled props become conflicting', async () => { + const activeSession = createMockSession({ + sessionId: 'workspace-active', + workspaceCwd: '/workspace/active', + events: createIdleEvents(), + }); + sdkMocks.sessions.push(activeSession); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'workspace-active', + sessionContext: { kind: 'workspace', cwd: '/workspace/active' }, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + + expect(connection).toMatchObject({ + status: 'error', + error: 'standalone session context cannot include workspaceCwd', + }); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).not.toHaveBeenCalled(); + expect(activeSession.detach).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining('/session/workspace-active/detach'), + expect.anything(), + ); + + await act(async () => { + await expect(requireActions(actions).createSession()).rejects.toThrow( + 'standalone session context cannot include workspaceCwd', + ); + }); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'error', + error: 'standalone session context cannot include workspaceCwd', + }); + expect(activeSession.detach).not.toHaveBeenCalled(); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining('/session/workspace-active/detach'), + expect.anything(), + ); + }); + + it('updates an empty controlled provider when its session context changes', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + sessionContext: { kind: 'workspace', cwd: '/primary' }, + }); + await act(async () => { + await vi.waitFor(() => + expect(connection).toMatchObject({ + status: 'connected', + sessionContext: { kind: 'workspace', cwd: '/primary' }, + }), + ); + }); + + sdkMocks.workspaceProviders.mockClear(); + sdkMocks.workspaceSkills.mockClear(); + sdkMocks.workspaceByCwd.mockClear(); + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + await act(async () => { + await vi.waitFor(() => + expect(connection).toMatchObject({ + status: 'connected', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + }), + ); + }); + + expect(sdkMocks.workspaceProviders).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceSkills).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceByCwd).not.toHaveBeenCalled(); + expect(connection?.commands).toBeUndefined(); + expect(connection?.skills).toBeUndefined(); + expect(connection?.models).toBeUndefined(); + expect(connection?.providers).toBeUndefined(); + expect(connection?.currentModel).toBeUndefined(); + expect(connection?.currentMode).toBeUndefined(); + expect(connection?.gitBranch).toBeUndefined(); + expect(connection?.gitStatus).toBeUndefined(); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).not.toHaveBeenCalled(); + }); + + it('does not reconnect for an equivalent inline session context', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + + function Harness() { + useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + sessionContext: { kind: 'standalone' }, + }); + await act(async () => { + await flushPromises(); + }); + expect(sdkMocks.capabilities).toHaveBeenCalledOnce(); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + + expect(sdkMocks.capabilities).toHaveBeenCalledOnce(); + }); + + it('keeps a baseUrl-only session when its connected id is echoed as a prop', async () => { + const keepConnected = createDeferred(); + const createdSession = createMockSession({ + sessionId: 'session-echo', + events: createPendingEvents(keepConnected), + }); + sdkMocks.sessions.push(createdSession); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + }); + await act(async () => requireActions(actions).newSession()); + await act(async () => { + await vi.waitFor(() => + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-echo', + }), + ); + }); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).toHaveBeenCalledOnce(); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-echo', + }); + expect(createdSession.detach).not.toHaveBeenCalled(); + keepConnected.resolve(); + }); + + it('reloads a controlled standalone session once after a baseUrl change', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-controlled', + workspaceCwd: '/private/standalone-controlled', + session: { + sessionId: 'standalone-controlled', + workspaceCwd: '/private/standalone-controlled', + sourceType: 'standalone', + context: { kind: 'standalone' }, + workingDirectory: { state: 'ready' }, + }, + }), + createMockSession({ + sessionId: 'standalone-controlled', + workspaceCwd: '/private/standalone-controlled', + session: { + sessionId: 'standalone-controlled', + workspaceCwd: '/private/standalone-controlled', + sourceType: 'standalone', + context: { kind: 'standalone' }, + workingDirectory: { state: 'ready' }, + }, + }), + ); + + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-controlled', + sessionContext: { kind: 'standalone' }, + }); + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenCalledTimes(1); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenCalledTimes(2); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'standalone-controlled', + sessionContext: { kind: 'standalone' }, + }); + expect(sdkMocks.daemonClientOptions).toContainEqual({ + baseUrl: 'http://127.0.0.1:4171', + token: undefined, + }); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).not.toHaveBeenCalled(); + }); + + it('settles a workspace load when the daemon reports a canonical cwd', async () => { + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'workspace-canonical', + workspaceCwd: '/private/tmp', + events: createIdleEvents(), + }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + }); + let loadPromise!: Promise; + await act(async () => { + loadPromise = requireActions(actions).loadSession('workspace-canonical', { + workspaceCwd: '/tmp', + }); + await flushPromises(); + }); + await expect(loadPromise).resolves.toBeUndefined(); + + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'workspace-canonical', + sessionContext: { kind: 'workspace', cwd: '/private/tmp' }, + workspaceCwd: '/private/tmp', + }); + }); + + it('does not inherit a failed controlled target in a baseUrl-only provider', async () => { + sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce( + new Error('load failed'), + ); + let actions: DaemonSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + let failedLoad!: Promise; + await act(async () => { + failedLoad = requireActions(actions).loadSession('failed-target', { + workspaceCwd: '/failed-target', + }); + void failedLoad.catch(() => undefined); + await flushPromises(); + }); + await expect(failedLoad).rejects.toThrow('load failed'); + + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'primary-target', + workspaceCwd: '/mock-workspace', + events: createIdleEvents(), + }), + ); + let primaryLoad!: Promise; + await act(async () => { + primaryLoad = requireActions(actions).loadSession('primary-target'); + await flushPromises(); + }); + await expect(primaryLoad).resolves.toBeUndefined(); + + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenLastCalledWith( + expect.anything(), + 'primary-target', + { workspaceCwd: '/mock-workspace', timeoutMs: 70_000 }, + expect.any(String), + ); + }); + + it('creates a fresh standalone session without the generic route', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-fresh', + workspaceCwd: '/private/standalone-fresh', + session: { + sessionId: 'standalone-fresh', + workspaceCwd: '/private/standalone-fresh', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-fresh', + workingDirectory: { state: 'ready' }, + }, + }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + sessionContext: { kind: 'standalone' }, + }); + await act(async () => { + await actions?.newSession(); + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).toHaveBeenCalledOnce(); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + sessionId: 'standalone-fresh', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + projectlessOutputDirectory: '/output/standalone-fresh', + workingDirectory: { state: 'ready' }, + }, + }); + }); + + it('surfaces direct standalone create recovery without retrying', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + const sessionId = '019cf000-0000-7000-8000-000000000003'; + const originalError = new DaemonHttpError( + 503, + { code: 'standalone_session_creating' }, + 'outcome unknown', + ); + sdkMocks.MockDaemonSessionClient.createStandalone.mockRejectedValueOnce( + new DaemonStandaloneCreationOutcomeUnknownError( + sessionId, + { state: 'creating', sessionId }, + originalError, + ), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + sessionContext: { kind: 'standalone' }, + }); + await act(async () => flushPromises()); + await act(async () => { + await requireActions(actions).newSession(); + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('error')); + }); + + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).toHaveBeenCalledOnce(); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'error', + sessionId, + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + creationRecovery: { state: 'creating', sessionId }, + errorCode: 'standalone_session_creating', + }, + }); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + + expect(connection).toMatchObject({ + status: 'error', + sessionId, + sessionContext: { kind: 'standalone' }, + standaloneSession: { + creationRecovery: { state: 'creating', sessionId }, + }, + }); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).toHaveBeenCalledOnce(); + + const recoveredSession = createMockSession({ + sessionId, + workspaceCwd: '/private/recovered-standalone', + session: { + sessionId, + workspaceCwd: '/private/recovered-standalone', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/recovered-standalone', + workingDirectory: { state: 'ready' }, + }, + events: createIdleEvents(), + }); + sdkMocks.sessions.push(recoveredSession); + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await wait(50); + await flushPromises(); + }); + expect(connection?.status).toBe('connected'); + + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenCalledOnce(); + expect(connection).toMatchObject({ + status: 'connected', + sessionId, + sessionContext: { kind: 'standalone' }, + standaloneSession: { + projectlessOutputDirectory: '/output/recovered-standalone', + }, + }); + }); + + it('preserves action-created standalone recovery across baseUrl-only dependency churn', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + const sessionId = '019cf000-0000-7000-8000-000000000004'; + const originalError = new DaemonHttpError( + 503, + { code: 'standalone_session_creating' }, + 'outcome unknown', + ); + sdkMocks.MockDaemonSessionClient.createStandalone.mockRejectedValueOnce( + new DaemonStandaloneCreationOutcomeUnknownError( + sessionId, + { state: 'creating', sessionId }, + originalError, + ), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; function Harness() { + actions = useDaemonActions(); connection = useDaemonConnection(); - blocks = useDaemonTranscriptBlocks(); return null; } - await renderWithProvider(); + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + await act(async () => { + await expect( + requireActions(actions).createSession({ + sessionContext: { kind: 'standalone' }, + }), + ).rejects.toBeInstanceOf(DaemonStandaloneCreationOutcomeUnknownError); + }); - expect(connection).toEqual({ status: 'idle' }); - expect(blocks).toEqual([]); + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + + expect(connection).toMatchObject({ + status: 'error', + sessionId, + sessionContext: { kind: 'standalone' }, + standaloneSession: { + creationRecovery: { state: 'creating', sessionId }, + }, + }); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).toHaveBeenCalledOnce(); }); - it('does not rerender streaming state consumers for equivalent transcript updates', async () => { - let store: DaemonTranscriptStore | undefined; - let renderCount = 0; + it('clears stale standalone identity after a plain fresh-create failure', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + const activeSession = createMockSession({ + sessionId: 'standalone-active', + workspaceCwd: '/private/standalone-active', + session: { + sessionId: 'standalone-active', + workspaceCwd: '/private/standalone-active', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-active', + workingDirectory: { state: 'ready' }, + }, + events: createIdleEvents(), + }); + sdkMocks.sessions.push(activeSession); + sdkMocks.MockDaemonSessionClient.createStandalone.mockRejectedValueOnce( + new Error('create failed'), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; function Harness() { - store = useDaemonTranscriptStore(); - useDaemonStreamingState(); - renderCount += 1; + actions = useDaemonActions(); + connection = useDaemonConnection(); return null; } - await renderWithProvider(); - const initialRenderCount = renderCount; + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-active', + sessionContext: { kind: 'standalone' }, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + await act(async () => requireActions(actions).newSession()); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('error')); + }); + + expect(connection).toMatchObject({ + status: 'error', + sessionContext: { kind: 'standalone' }, + error: 'create failed', + }); + expect(connection?.sessionId).toBeUndefined(); + expect(connection?.displayName).toBeUndefined(); + expect(connection?.standaloneSession?.creationRecovery).toBeUndefined(); act(() => { - store?.appendLocalUserMessage('first'); - store?.appendLocalUserMessage('second'); + root?.render( + + + , + ); }); + await act(async () => flushPromises()); - expect(renderCount).toBe(initialRenderCount); + expect(connection).toMatchObject({ + status: 'error', + sessionContext: { kind: 'standalone' }, + error: 'create failed', + standaloneSession: { errorCode: undefined }, + }); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).toHaveBeenCalledOnce(); }); - it('keeps capabilities handshake failures out of the transcript', async () => { - sdkMocks.capabilities.mockRejectedValue( - Object.assign(new Error('GET /capabilities: HTTP 400'), { status: 400 }), + it('surfaces a standalone create capability failure without fallback', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + sdkMocks.MockDaemonSessionClient.createStandalone.mockRejectedValueOnce( + new DaemonCapabilityMissingError( + 'standalone_sessions_v1', + 'standalone sessions are unavailable', + ), ); + let actions: DaemonSessionActions | undefined; let connection: DaemonConnectionState | undefined; - let blocks: readonly DaemonTranscriptBlock[] = []; function Harness() { + actions = useDaemonActions(); connection = useDaemonConnection(); - blocks = useDaemonTranscriptBlocks(); return null; } await renderWithProvider(, { autoConnect: true, - autoReconnect: false, + sessionContext: { kind: 'standalone' }, }); - + await act(async () => requireActions(actions).newSession()); await act(async () => { - await flushPromises(); + await vi.waitFor(() => expect(connection?.status).toBe('error')); }); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).toHaveBeenCalledOnce(); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); expect(connection).toMatchObject({ status: 'error', - error: 'GET /capabilities: HTTP 400', + sessionContext: { kind: 'standalone' }, + standaloneSession: { errorCode: 'standalone_sessions_v1' }, }); - expect(blocks).toEqual([]); + expect(connection?.workspaceCwd).toBeUndefined(); }); - it('connects without creating a session by default', async () => { - sdkMocks.workspaceProviders.mockResolvedValueOnce({ - v: 1, - workspaceCwd: '/mock-workspace', - initialized: true, - approvalMode: 'yolo', - providers: [], + it('fails a standalone load once when its capability is unavailable', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], }); + sdkMocks.MockDaemonSessionClient.loadStandalone.mockRejectedValueOnce( + new DaemonCapabilityMissingError( + 'standalone_sessions_v1', + 'standalone sessions are unavailable', + ), + ); let connection: DaemonConnectionState | undefined; function Harness() { @@ -534,18 +1762,33 @@ describe('DaemonSessionProvider', () => { await renderWithProvider(, { autoConnect: true, + autoReconnect: true, + sessionId: 'standalone-missing-capability', + sessionContext: { kind: 'standalone' }, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('error')); + }); + await act(async () => { + await wait(20); + await flushPromises(); }); expect( - sdkMocks.MockDaemonSessionClient.createOrAttach, + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenCalledOnce(); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, ).not.toHaveBeenCalled(); expect(connection).toMatchObject({ - status: 'connected', - workspaceCwd: '/mock-workspace', - currentMode: 'yolo', - gitBranch: 'main', + status: 'error', + sessionContext: { kind: 'standalone' }, + standaloneSession: { errorCode: 'standalone_sessions_v1' }, }); - expect(connection).not.toHaveProperty('sessionId'); + expect(connection?.workspaceCwd).toBeUndefined(); }); it('keeps model preview separate until live reasoning context is authoritative', async () => { @@ -1160,24 +2403,231 @@ describe('DaemonSessionProvider', () => { autoConnect: true, sessionId: undefined, }); - expect( - sdkMocks.MockDaemonSessionClient.createOrAttach, - ).not.toHaveBeenCalled(); - + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + + await act(async () => { + await actions?.createSession(); + }); + + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + sessionScope: 'thread', + workspaceCwd: '/mock-workspace', + }), + expect.any(String), + ); + }); + + it('keeps an explicit standalone context through detached create and attach', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + const standaloneSession = createMockSession({ + sessionId: 'standalone-created', + workspaceCwd: '/private/standalone-created', + session: { + sessionId: 'standalone-created', + workspaceCwd: '/private/standalone-created', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-created', + workingDirectory: { state: 'ready' }, + }, + }); + sdkMocks.sessions.push(standaloneSession); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + createSessionRequest: { approvalMode: 'yolo' }, + }); + sdkMocks.workspaceProviders.mockClear(); + sdkMocks.workspaceSkills.mockClear(); + sdkMocks.workspaceByCwd.mockClear(); + + const providerActions = requireActions(actions); + await act(async () => { + await providerActions.createSession({ + sessionContext: { kind: 'standalone' }, + }); + }); + + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).toHaveBeenCalledWith(expect.anything(), { approvalMode: 'yolo' }); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'standalone-created', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + projectlessOutputDirectory: '/output/standalone-created', + workingDirectory: { state: 'ready' }, + }, + }); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => flushPromises()); + + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'standalone-created', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + projectlessOutputDirectory: '/output/standalone-created', + }, + }); + expect(sdkMocks.workspaceProviders).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceSkills).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceByCwd).not.toHaveBeenCalled(); + + let attach: Promise | undefined; + act(() => { + attach = providerActions.attachSession(); + }); + await act(async () => { + await flushPromises(); + }); + await attach; + + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'standalone-created', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + }); + expect(sdkMocks.workspaceProviders).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceSkills).not.toHaveBeenCalled(); + expect(sdkMocks.workspaceByCwd).not.toHaveBeenCalled(); + expect(standaloneSession.supportedCommands).toHaveBeenCalledOnce(); + }); + + it('returns to the workspace prop after clearing a standalone restore', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(undefined, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/workspace', + features: ['standalone_sessions_v1'], + }); + const initialWorkspace = createMockSession({ + sessionId: 'workspace-initial', + workspaceCwd: '/workspace', + events: createIdleEvents(), + }); + const loadedStandalone = createMockSession({ + sessionId: 'standalone-loaded', + workspaceCwd: '/private/standalone-loaded', + session: { + sessionId: 'standalone-loaded', + workspaceCwd: '/private/standalone-loaded', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-loaded', + workingDirectory: { state: 'ready' }, + }, + events: createIdleEvents(), + }); + const createdWorkspace = createMockSession({ + sessionId: 'workspace-created', + workspaceCwd: '/workspace', + events: createIdleEvents(), + }); + sdkMocks.sessions.push(initialWorkspace); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'workspace-initial', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + }); + sdkMocks.sessions.push(loadedStandalone, createdWorkspace); + let loadStandalone!: Promise; + act(() => { + loadStandalone = requireActions(actions).loadSession( + 'standalone-loaded', + { + sessionContext: { kind: 'standalone' }, + }, + ); + }); + await act(async () => { + await wait(5); + await flushPromises(); + }); + await expect(loadStandalone).resolves.toBeUndefined(); + await act(async () => { + await vi.waitFor(() => + expect(connection?.sessionContext).toEqual({ kind: 'standalone' }), + ); + }); + let newWorkspaceSession!: Promise; + act(() => { + newWorkspaceSession = requireActions(actions).newSession(); + }); + await act(async () => { + await wait(5); + await flushPromises(); + }); + await expect(newWorkspaceSession).resolves.toBeUndefined(); await act(async () => { - await actions?.createSession(); + await vi.waitFor(() => + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'workspace-created', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + }), + ); }); expect( sdkMocks.MockDaemonSessionClient.createOrAttach, - ).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - sessionScope: 'thread', - workspaceCwd: '/mock-workspace', - }), - expect.any(String), - ); + ).toHaveBeenCalledOnce(); + expect( + sdkMocks.MockDaemonSessionClient.createStandalone, + ).not.toHaveBeenCalled(); }); it('can send immediately after creating a session from the empty state', async () => { @@ -3343,6 +4793,74 @@ describe('DaemonSessionProvider', () => { }); }); + it('does not invalidate workspace data from standalone session events', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-signals', + workspaceCwd: '/private/standalone-signals', + session: { + sessionId: 'standalone-signals', + workspaceCwd: '/private/standalone-signals', + sourceType: 'standalone', + context: { kind: 'standalone' }, + workingDirectory: { state: 'ready' }, + }, + events: async function* standaloneWorkspaceEvents() { + yield { + id: 21, + v: 1, + type: 'memory_changed', + data: { + scope: 'workspace', + filePath: '/private/standalone-signals/QWEN.md', + mode: 'append', + bytesWritten: 12, + }, + } satisfies DaemonEvent; + yield { + id: 22, + v: 1, + type: 'settings_changed', + data: { + key: 'ui.theme', + scope: 'workspace', + value: 'Qwen Dark', + }, + } satisfies DaemonEvent; + }, + }), + ); + let signals: DaemonWorkspaceEventSignals | undefined; + + function Harness() { + signals = useDaemonWorkspaceEventSignals(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-signals', + sessionContext: { kind: 'standalone' }, + }); + await act(async () => flushPromises()); + + expect(signals).toMatchObject({ + memoryVersion: 0, + agentsVersion: 0, + toolsVersion: 0, + settingsVersion: 0, + skillsVersion: 0, + mcpVersion: 0, + artifactsVersion: 0, + initVersion: 0, + authVersion: 0, + }); + }); + it('deduplicates skill toggle settings events from the generic settings signal', async () => { const mutation = { id: 'skill-toggle-1', @@ -6970,6 +8488,88 @@ describe('DaemonSessionProvider', () => { expect(promptStatus).toBe('streaming'); }); + it('reloads standalone sessions through the standalone route after resync', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + const reloaded = createDeferred(); + const firstSession = createMockSession({ + sessionId: 'standalone-resync', + workspaceCwd: '/private/standalone-resync', + session: { + sessionId: 'standalone-resync', + workspaceCwd: '/private/standalone-resync', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-resync', + workingDirectory: { state: 'ready' }, + }, + events: async function* standaloneEpochReset() { + yield { + id: 6, + v: 1, + type: 'state_resync_required', + data: { reason: 'epoch_reset' }, + } satisfies DaemonEvent; + }, + }); + const reloadedSession = createMockSession({ + sessionId: 'standalone-resync', + workspaceCwd: '/private/standalone-resync', + session: { + sessionId: 'standalone-resync', + workspaceCwd: '/private/standalone-resync', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-resync', + workingDirectory: { state: 'ready' }, + }, + events: createPendingEvents(reloaded), + }); + sdkMocks.sessions.push(firstSession, reloadedSession); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-resync', + sessionContext: { kind: 'standalone' }, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await reloaded.promise; + await flushPromises(); + }); + + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenCalledTimes(2); + expect( + sdkMocks.MockDaemonSessionClient.loadStandalone, + ).toHaveBeenLastCalledWith( + expect.anything(), + 'standalone-resync', + { timeoutMs: 70_000 }, + expect.any(String), + ); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'standalone-resync', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + workingDirectory: { state: 'ready' }, + }, + }); + }); + it('clears restored active prompts when epoch reload is idle', async () => { const reloaded = createDeferred(); const firstSession = createMockSession({ @@ -9349,6 +10949,117 @@ describe('DaemonSessionProvider', () => { }); }); + it('retains standalone identity after transient heartbeat failures', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat', 'standalone_sessions_v1'], + modelServices: [], + workspaceCwd: '/primary', + }); + const heartbeat = vi.fn(async () => { + throw new Error('heartbeat lost'); + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-heartbeat', + workspaceCwd: '/private/standalone-heartbeat', + session: { + sessionId: 'standalone-heartbeat', + workspaceCwd: '/private/standalone-heartbeat', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-heartbeat', + workingDirectory: { state: 'ready' }, + }, + heartbeat, + events: createIdleEvents(), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-heartbeat', + sessionContext: { kind: 'standalone' }, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 1, + }); + await act(async () => { + await wait(50); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'disconnected', + sessionId: 'standalone-heartbeat', + sessionContext: { kind: 'standalone' }, + standaloneSession: { workingDirectory: { state: 'ready' } }, + error: 'heartbeat lost', + }); + }); + + it('clears standalone identity after a terminal heartbeat failure', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat', 'standalone_sessions_v1'], + modelServices: [], + workspaceCwd: '/primary', + }); + const heartbeat = vi.fn(async () => { + throw Object.assign(new Error('session gone'), { status: 410 }); + }); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-gone', + workspaceCwd: '/private/standalone-gone', + session: { + sessionId: 'standalone-gone', + workspaceCwd: '/private/standalone-gone', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-gone', + workingDirectory: { state: 'ready' }, + }, + heartbeat, + events: createIdleEvents(), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'standalone-gone', + sessionContext: { kind: 'standalone' }, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 1, + }); + await act(async () => { + await wait(50); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'disconnected', + missingSession: true, + }); + expect(connection?.sessionId).toBeUndefined(); + expect(connection?.sessionContext).toEqual({ kind: 'standalone' }); + expect(connection?.workspaceCwd).toBeUndefined(); + expect(connection?.standaloneSession).toBeUndefined(); + }); + it('ignores a late heartbeat failure from a replaced same-id attachment', async () => { vi.stubGlobal( 'fetch', @@ -10508,6 +12219,35 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ sessionId: 'session-a' }); }); + it('publishes the loaded workspace instead of the primary fallback', async () => { + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + workspaceCwd: '/secondary-workspace', + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + }); + + expect(connection).toMatchObject({ + sessionId: 'session-a', + sessionContext: { + kind: 'workspace', + cwd: '/secondary-workspace', + }, + workspaceCwd: '/secondary-workspace', + }); + }); + it('does not duplicate the initial controlled load when workspace is set', async () => { sdkMocks.sessions.push( createMockSession({ sessionId: 'session-a', clientId: 'client-a' }), @@ -11151,6 +12891,83 @@ describe('DaemonSessionProvider', () => { }, ); + it('clears standalone session metadata after an auth failure', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + const streamFailure = createDeferred(); + const streamStarted = createDeferred(); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'standalone-auth', + workspaceCwd: '/private/standalone-auth', + session: { + sessionId: 'standalone-auth', + workspaceCwd: '/private/standalone-auth', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-auth', + workingDirectory: { state: 'ready' }, + }, + context: vi.fn(async () => sessionContextWithModels('standalone-auth')), + supportedCommands: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'standalone-auth', + availableCommands: [ + { name: '/context', description: 'Show context', input: null }, + ], + availableSkills: ['review'], + })), + events: async function* authFailureEvents() { + streamStarted.resolve(); + await streamFailure.promise; + yield* []; + throw new DaemonHttpError(401, {}, 'Unauthorized'); + }, + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, + sessionId: 'standalone-auth', + sessionContext: { kind: 'standalone' }, + }); + await act(async () => { + await vi.waitFor(() => expect(connection?.status).toBe('connected')); + await streamStarted.promise; + }); + expect(connection?.commands?.length).toBeGreaterThan(0); + expect(connection?.models).toHaveLength(1); + + streamFailure.resolve(); + await act(async () => { + await wait(20); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'error', + sessionContext: { kind: 'standalone' }, + error: 'Unauthorized', + errorStatus: 401, + }); + expect(connection?.sessionId).toBeUndefined(); + expect(connection?.commands).toBeUndefined(); + expect(connection?.skills).toBeUndefined(); + expect(connection?.models).toBeUndefined(); + expect(connection?.supportedCommands).toBeUndefined(); + expect(connection?.context).toBeUndefined(); + expect(connection?.standaloneSession).toBeUndefined(); + }); + it.each([ [ 'cancel', @@ -15229,6 +17046,13 @@ function createMockSession(opts: Partial = {}): MockSession { replayPartial: opts.replayPartial ?? false, replayError: opts.replayError, eventEpoch: opts.eventEpoch ?? 'epoch-1', + session: + opts.session ?? + ({ + sessionId: opts.sessionId ?? 'session-1', + workspaceCwd: opts.workspaceCwd ?? '/mock-workspace', + sourceType: 'default', + } as Record), lastEventId: opts.lastEventId, setLastEventId: opts.setLastEventId ?? diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index b9debc05224..8fdee2f1d22 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -17,9 +17,12 @@ import { useSyncExternalStore, } from 'react'; import { + DAEMON_APPROVAL_MODES, DaemonClient, + DaemonCapabilityMissingError, DaemonHttpError, DaemonSessionClient, + DaemonStandaloneCreationOutcomeUnknownError, UNRECOGNIZED_DIAGNOSTICS_LIMIT, createDaemonTranscriptStore, estimateDaemonTranscriptBlockBytes, @@ -30,6 +33,7 @@ import { matchTurnEvent, normalizeDaemonEvent, type CreateSessionRequest, + type DaemonApprovalMode, type DaemonEvent, type DaemonSseConnectReason, type DaemonTranscriptBlock, @@ -42,9 +46,9 @@ import { } from '@qwen-code/sdk/daemon'; import { createDaemonSessionActions, - getWorkspaceModelsAfterSessionClear, + getConnectionAfterSessionClear, getPromptSettledKey, - normalizeWorkspaceIdentity, + getWorkspaceModelsAfterSessionClear, resolveSessionRestoreTimeouts, } from './actions.js'; import { @@ -60,6 +64,15 @@ import { persistStableClientId, } from './clientLifecycle.js'; import { extractHttpStatus, isRecord } from './httpErrors.js'; +import { + getDaemonErrorCode, + getStandaloneConnectionState, + isDaemonErrorExplicitlyNonRetryable, + resolveLiveSessionWorkspaceCwd, + resolveProviderSessionContext, + restoreSessionContextMatches, + sessionContextKey, +} from './session-context.js'; import { useOptionalDaemonWorkspace } from '../workspace/DaemonWorkspaceProvider.js'; import { getCurrentMode, @@ -113,6 +126,7 @@ import type { DaemonSessionNotice, DaemonSessionOwnerGuard, DaemonSessionProviderProps, + DaemonProductSessionContext, DaemonWorkspaceEventSignals, PendingSessionLoad, SettledPrompt, @@ -123,6 +137,8 @@ export type { DaemonConnectionState, DaemonConnectionStatus, DaemonModelInfo, + DaemonProductSessionContext, + DaemonStandaloneConnectionState, DaemonNoticeCategory, DaemonNoticeOperation, DaemonNoticeSeverity, @@ -185,6 +201,15 @@ 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'; +function resolveStandaloneApprovalMode( + value: string | undefined, +): DaemonApprovalMode | undefined { + if (value === undefined) return undefined; + if (DAEMON_APPROVAL_MODES.includes(value as DaemonApprovalMode)) { + return value as DaemonApprovalMode; + } + throw new Error(`Unsupported standalone approval mode: ${value}`); +} // Cap the daemon-advertised restore retry delay: an unbounded value overflows // setTimeout's 2^31-1 ms limit (firing instantly, retry storm) or leaves the // UI stuck connecting for hours. @@ -687,11 +712,32 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { const UNHANDLED_SESSION = Symbol('unhandled session'); +function clearNonWorkspaceSessionState( + current: DaemonConnectionState, +): DaemonConnectionState { + return current.sessionContext !== undefined && + current.sessionContext.kind !== 'workspace' + ? getConnectionAfterSessionClear(current, current.sessionId) + : current; +} + +function useStableProductSessionContext( + context: DaemonProductSessionContext | undefined, +): DaemonProductSessionContext | undefined { + const identity = sessionContextKey(context); + const stableRef = useRef({ identity, context }); + if (stableRef.current.identity !== identity) { + stableRef.current = { identity, context }; + } + return stableRef.current.context; +} + export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const { baseUrl, token, workspaceCwd, + sessionContext, sessionId, clientId, createSessionRequest, @@ -715,7 +761,29 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const workspace = useOptionalDaemonWorkspace(); const resolvedBaseUrl = baseUrl ?? workspace?.baseUrl; const resolvedToken = token ?? workspace?.token; - const resolvedWorkspaceCwd = workspaceCwd ?? workspace?.workspaceCwd; + const sessionContextResolution = useMemo(() => { + try { + return { + value: resolveProviderSessionContext( + sessionContext, + workspaceCwd, + workspace?.workspaceCwd, + ), + }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + }; + } + }, [sessionContext, workspace?.workspaceCwd, workspaceCwd]); + const resolvedSessionContext = useStableProductSessionContext( + sessionContextResolution.value, + ); + const sessionContextResolutionError = sessionContextResolution.error; + const resolvedWorkspaceCwd = + resolvedSessionContext?.kind === 'workspace' + ? resolvedSessionContext.cwd + : undefined; const sessionCapabilitiesRef = useRef( workspace?.capabilities, ); @@ -735,6 +803,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { initialRestoreSessionId === undefined; const resolvedWorkspaceCwdRef = useRef(resolvedWorkspaceCwd); resolvedWorkspaceCwdRef.current = resolvedWorkspaceCwd; + const resolvedSessionContextRef = useRef(resolvedSessionContext); + resolvedSessionContextRef.current = resolvedSessionContext; + const sessionContextResolutionErrorRef = useRef( + sessionContextResolutionError, + ); + sessionContextResolutionErrorRef.current = sessionContextResolutionError; + const activeSessionContextRef = useRef(resolvedSessionContext); const activeWorkspaceCwdRef = useRef(resolvedWorkspaceCwd); if (resolvedWorkspaceCwd) { activeWorkspaceCwdRef.current = resolvedWorkspaceCwd; @@ -967,6 +1042,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const skipNextCleanupDetachSessionRef = useRef< DaemonSessionClient | undefined >(undefined); + const contextErrorPreservedSessionRef = useRef< + DaemonSessionClient | undefined + >(undefined); + const contextErrorPreservedProductContextRef = useRef< + DaemonProductSessionContext | undefined + >(undefined); const settledRestoredActivePromptSessionsRef = useRef< WeakSet >(new WeakSet()); @@ -992,17 +1073,27 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const [restoreSessionId, setRestoreSessionId] = useState( initialRestoreSessionId, ); - const [restoreWorkspaceCwd, setRestoreWorkspaceCwd] = useState< - string | undefined - >(undefined); + const [restoreSessionContext, setRestoreSessionContext] = useState< + DaemonProductSessionContext | undefined + >(resolvedSessionContext); const [restoreMode, setRestoreMode] = useState<'load' | 'resume'>('load'); const [restoreSessionNonce, setRestoreSessionNonce] = useState(0); const [attachSessionNonce, setAttachSessionNonce] = useState(0); const [newSessionNonce, setNewSessionNonce] = useState(0); const [connection, setConnection] = useState({ - status: autoConnect ? 'connecting' : 'idle', + status: sessionContextResolutionError + ? 'error' + : autoConnect + ? 'connecting' + : 'idle', ...(initialRestoreSessionId ? { sessionId: initialRestoreSessionId } : {}), + ...(resolvedSessionContext + ? { sessionContext: resolvedSessionContext } + : {}), ...(resolvedWorkspaceCwd ? { workspaceCwd: resolvedWorkspaceCwd } : {}), + ...(sessionContextResolutionError + ? { error: sessionContextResolutionError } + : {}), }); const connectionRef = useRef(connection); connectionRef.current = connection; @@ -1084,10 +1175,21 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }; }, []); - const sessionEffectWorkspaceCwd = restoreWorkspaceCwd ?? workspaceCwd; + const sessionEffectContext = restoreSessionContext ?? resolvedSessionContext; useEffect(() => { if (!autoConnect) return undefined; + if (sessionContextResolutionError) { + setConnectionSynchronous((current) => ({ + ...current, + status: 'error', + error: sessionContextResolutionError, + })); + return undefined; + } + if (sessionEffectContext) { + activeSessionContextRef.current = sessionEffectContext; + } if (!workspaceClientRef.current && !resolvedBaseUrl) { setConnection({ status: 'error', @@ -1239,7 +1341,19 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { let reconnectAttempt = 0; let nextSseConnectReason: DaemonSseConnectReason | undefined; let skipMetadataRefresh = false; + let standaloneCreateAttempted = false; + let productContextFailure = false; let hasCurrentSessionActivePrompt = () => false; + if ( + !restoreSessionId && + !reconnectSessionId && + !shouldCreateFreshSession && + (connectionRef.current.standaloneSession?.creationRecovery || + (manualSessionClearRef.current && + connectionRef.current.status === 'error')) + ) { + return; + } // Set when the user explicitly deletes the session (server // publishes session_closed with reason 'client_close'). // Reconnecting would auto-create a new session, undoing the @@ -1303,16 +1417,29 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { let repairSuffix: LiveJournalRepairSuffix | undefined; if (!session) { const existingSession = sessionRef.current; + const reusingContextErrorSession = + existingSession !== undefined && + existingSession === contextErrorPreservedSessionRef.current && + restoreSessionId === existingSession.sessionId && + sessionContextKey(sessionEffectContext) === + sessionContextKey( + contextErrorPreservedProductContextRef.current, + ); if ( existingSession && - !restoreSessionId && - !reconnectSessionId && - !shouldCreateFreshSession + ((!restoreSessionId && + !reconnectSessionId && + !shouldCreateFreshSession) || + reusingContextErrorSession) ) { session = existingSession; reconnectSessionId = existingSession.sessionId; lastSessionIdRef.current = existingSession.sessionId; attachedExistingSession = true; + if (reusingContextErrorSession) { + contextErrorPreservedSessionRef.current = undefined; + contextErrorPreservedProductContextRef.current = undefined; + } } } if (!session) { @@ -1343,15 +1470,38 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { heartbeatSupportedRef.current = Array.isArray(caps.features) && caps.features.includes('client_heartbeat'); - const effectWorkspaceCwd = - restoreWorkspaceCwd ?? - resolvedWorkspaceCwdRef.current ?? - caps.workspaceCwd; + const legacyWorkspaceCwd = + resolvedWorkspaceCwdRef.current ?? caps.workspaceCwd; + const effectSessionContext = + restoreSessionContext ?? + resolvedSessionContextRef.current ?? + (legacyWorkspaceCwd + ? { + kind: 'workspace' as const, + cwd: legacyWorkspaceCwd, + } + : undefined); + let effectWorkspaceCwd: string | undefined; + if (effectSessionContext?.kind === 'workspace') { + effectWorkspaceCwd = effectSessionContext.cwd; + } else if (effectSessionContext?.kind === 'live') { + try { + effectWorkspaceCwd = resolveLiveSessionWorkspaceCwd(caps); + } catch (error) { + productContextFailure = true; + throw error; + } + } + const workspaceScoped = + effectSessionContext?.kind === 'workspace' || + effectSessionContext === undefined; + activeSessionContextRef.current = effectSessionContext; activeWorkspaceCwdRef.current = effectWorkspaceCwd; const capabilityFeatures = Array.isArray(caps.features) ? caps.features : []; const canPreheatPrimaryWorkspace = + workspaceScoped && effectWorkspaceCwd === caps.workspaceCwd && capabilityFeatures.includes(WORKSPACE_ACP_PREHEAT_FEATURE); const canReadPrimaryAcpStatus = @@ -1364,6 +1514,32 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { !reconnectSessionId && !shouldCreateFreshSession ) { + if (!workspaceScoped) { + setConnection((current) => + current.status === 'error' + ? current + : { + ...clearNonWorkspaceSessionState(current), + status: 'connected', + sessionContext: effectSessionContext, + workspaceCwd: undefined, + standaloneSession: undefined, + gitBranch: undefined, + gitStatus: undefined, + commands: undefined, + skills: undefined, + models: undefined, + currentModel: undefined, + currentMode: undefined, + contextWindow: undefined, + providers: undefined, + capabilities: caps, + error: undefined, + errorStatus: undefined, + }, + ); + return; + } // Fetch skills alongside providers so skill-backed slash // commands (e.g. /review) can autocomplete before the first // prompt. Both are session-less workspace queries; the @@ -1422,7 +1598,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection((current) => ({ ...current, status: 'connected', + sessionContext: effectSessionContext, workspaceCwd: effectWorkspaceCwd, + standaloneSession: undefined, gitBranch: gitResult.status === 'fulfilled' ? (gitResult.value.branch ?? undefined) @@ -1495,12 +1673,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { targetSessionId === connectionRef.current.sessionId && connectionRef.current.clientId !== undefined && requestClientId !== connectionRef.current.clientId; - const restoreMethod = - restoreSessionId && + const shouldResumeRequestedSession = + Boolean(restoreSessionId) && restoreMode === 'resume' && - !legacyClientRebind - ? DaemonSessionClient.resume - : DaemonSessionClient.load; + !legacyClientRebind; loadingRequestedSession = Boolean(restoreSessionId); if (targetSessionId && !preservingTranscriptDuringLoad) { setConnection((current) => ({ @@ -1512,65 +1688,119 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { loadingTranscript: true, })); } + const currentPendingLoad = pendingSessionLoadRef.current; const attemptedLoad = - pendingSessionLoadRef.current?.sessionId === targetSessionId - ? pendingSessionLoadRef.current + currentPendingLoad !== undefined && + currentPendingLoad.sessionId === targetSessionId && + restoreSessionContextMatches( + currentPendingLoad.sessionContext, + effectSessionContext, + ) + ? currentPendingLoad : undefined; const restoreRequestTimeoutMs = attemptedLoad?.requestTimeoutMs ?? resolveSessionRestoreTimeouts(capabilities).requestTimeoutMs; - const nextSession = restoreSessionId - ? await restoreMethod( - client, - restoreSessionId, - { - workspaceCwd: effectWorkspaceCwd, - timeoutMs: restoreRequestTimeoutMs, - ...(restoreMethod === DaemonSessionClient.load && - subagentTranscriptModeRef.current === 'summary' - ? { liveReplayMode: 'summary' as const } - : {}), - ...(historyPaginationSupported && - restoreMode === 'load' && - attemptedLoad?.replaySource !== 'memory' && - historyPageSizeRef.current !== undefined - ? { historyPageSize: historyPageSizeRef.current } + const restoreRequest = { + timeoutMs: restoreRequestTimeoutMs, + ...(!shouldResumeRequestedSession && + subagentTranscriptModeRef.current === 'summary' + ? { liveReplayMode: 'summary' as const } + : {}), + ...(historyPaginationSupported && + (!restoreSessionId || restoreMode === 'load') && + attemptedLoad?.replaySource !== 'memory' && + historyPageSizeRef.current !== undefined + ? { historyPageSize: historyPageSizeRef.current } + : {}), + }; + let nextSession: DaemonSessionClient; + if (restoreSessionId) { + if (effectSessionContext?.kind === 'standalone') { + nextSession = shouldResumeRequestedSession + ? await DaemonSessionClient.resumeStandalone( + client, + restoreSessionId, + restoreRequest, + requestClientId, + ) + : await DaemonSessionClient.loadStandalone( + client, + restoreSessionId, + restoreRequest, + requestClientId, + ); + } else { + nextSession = shouldResumeRequestedSession + ? await DaemonSessionClient.resume( + client, + restoreSessionId, + { + ...restoreRequest, + workspaceCwd: effectWorkspaceCwd, + }, + requestClientId, + ) + : await DaemonSessionClient.load( + client, + restoreSessionId, + { + ...restoreRequest, + workspaceCwd: effectWorkspaceCwd, + }, + requestClientId, + ); + } + } else if (reconnectSessionId) { + nextSession = + effectSessionContext?.kind === 'standalone' + ? await DaemonSessionClient.loadStandalone( + client, + reconnectSessionId, + restoreRequest, + requestClientId, + ) + : await DaemonSessionClient.load( + client, + reconnectSessionId, + { + ...restoreRequest, + workspaceCwd: effectWorkspaceCwd, + }, + requestClientId, + ); + } else if (effectSessionContext?.kind === 'standalone') { + standaloneCreateAttempted = true; + nextSession = await DaemonSessionClient.createStandalone(client, { + ...(modelServiceId !== undefined ? { modelServiceId } : {}), + ...(createSessionRequestRef.current?.approvalMode !== undefined + ? { + approvalMode: resolveStandaloneApprovalMode( + createSessionRequestRef.current.approvalMode, + ), + } + : {}), + }); + standaloneCreateAttempted = false; + } else { + if (effectSessionContext?.kind === 'live') { + productContextFailure = true; + throw new Error('Live session context does not support create'); + } + nextSession = await DaemonSessionClient.createOrAttach( + client, + { + ...(modelServiceId !== undefined ? { modelServiceId } : {}), + ...(shouldCreateFreshSession + ? { sessionScope: 'thread' as const } + : sessionScope !== undefined + ? { sessionScope } : {}), - }, - requestClientId, - ) - : reconnectSessionId - ? await DaemonSessionClient.load( - client, - reconnectSessionId, - { - workspaceCwd: effectWorkspaceCwd, - timeoutMs: restoreRequestTimeoutMs, - ...(subagentTranscriptModeRef.current === 'summary' - ? { liveReplayMode: 'summary' as const } - : {}), - ...(historyPaginationSupported && - historyPageSizeRef.current !== undefined - ? { historyPageSize: historyPageSizeRef.current } - : {}), - }, - requestClientId, - ) - : await DaemonSessionClient.createOrAttach( - client, - { - ...(modelServiceId !== undefined - ? { modelServiceId } - : {}), - ...(shouldCreateFreshSession - ? { sessionScope: 'thread' as const } - : sessionScope !== undefined - ? { sessionScope } - : {}), - workspaceCwd: effectWorkspaceCwd, - }, - requestClientId, - ); + workspaceCwd: effectWorkspaceCwd, + }, + requestClientId, + ); + } loadingRequestedSession = false; if (!legacyClientIdDependency && nextSession.clientId) { clientIdRef.current = nextSession.clientId; @@ -1742,6 +1972,21 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } const activeSession = session; + const activeStandaloneState = getStandaloneConnectionState( + activeSession.session, + ); + const activeProductSessionContext = + activeStandaloneState !== undefined || + activeSessionContextRef.current?.kind === 'standalone' + ? ({ kind: 'standalone' } as const) + : activeSessionContextRef.current?.kind === 'live' + ? activeSessionContextRef.current + : { + kind: 'workspace' as const, + cwd: activeSession.workspaceCwd, + }; + const activeWorkspaceScoped = + activeProductSessionContext.kind === 'workspace'; runnerSession = activeSession; // Prompt activity is session state returned by /load. Surface it // immediately so a refreshed page shows the running state without @@ -1776,9 +2021,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const pendingLoad = pendingSessionLoadRef.current; const pendingLoadToResolve = - pendingLoad?.sessionId === activeSession.sessionId + pendingLoad?.sessionId === activeSession.sessionId && + restoreSessionContextMatches( + pendingLoad.sessionContext, + activeSessionContextRef.current, + ) ? pendingLoad : undefined; + activeSessionContextRef.current = activeProductSessionContext; // Feed replay snapshot (compacted history + live journal) into // the store before starting the SSE loop. The SSE stream begins @@ -2134,7 +2384,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const sideEffectEvents = eventGroups.flatMap( (group) => group.sideEffects, ); - if (sideEffectEvents.length > 0) { + if (activeWorkspaceScoped && sideEffectEvents.length > 0) { bumpWorkspaceEventSignals( sideEffectEvents, setWorkspaceEventSignals, @@ -2215,10 +2465,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connected', sessionId: activeSession.sessionId, + sessionContext: activeProductSessionContext, ...(activeSession.clientId ? { clientId: activeSession.clientId } : {}), - workspaceCwd: activeSession.workspaceCwd, + workspaceCwd: activeWorkspaceScoped + ? activeProductSessionContext.cwd + : undefined, + standaloneSession: + activeProductSessionContext.kind === 'standalone' + ? getStandaloneConnectionState(activeSession.session) + : undefined, displayName: getSessionDisplayName(activeSession.state) ?? (current.sessionId === activeSession.sessionId @@ -2249,7 +2506,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { })); if (pendingLoadToResolve) { lastHandledSessionIdRef.current = activeSession.sessionId; - lastHandledWorkspaceRef.current = activeSession.workspaceCwd; + lastHandledSessionContextRef.current = activeProductSessionContext; lastHandledClientIdRef.current = undefined; pendingSessionLoadRef.current = undefined; if (pendingLoadToResolve.timeout !== undefined) { @@ -2276,11 +2533,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { : undefined; const gitPromise = skipMetadataRefreshThisIteration ? Promise.resolve({ branch: connectionRef.current.gitBranch }) - : activeSession.workspaceCwd - ? client.workspaceByCwd(activeSession.workspaceCwd).workspaceGit() - : client.workspaceGit(); + : activeWorkspaceScoped + ? activeSession.workspaceCwd + ? client + .workspaceByCwd(activeSession.workspaceCwd) + .workspaceGit() + : client.workspaceGit() + : Promise.resolve(undefined); const metadataPromise = Promise.allSettled([ - canReuseSessionMetadata + canReuseSessionMetadata || !activeWorkspaceScoped ? Promise.resolve(undefined) : client.workspaceProviders(), canReuseSessionMetadata @@ -2360,7 +2621,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { : undefined; const gitBranch = gitResult?.status === 'fulfilled' - ? (gitResult.value.branch ?? undefined) + ? (gitResult.value?.branch ?? undefined) : undefined; const goalStateFallback = goalState === undefined && goalStateAtLoadStart === undefined @@ -2423,7 +2684,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...(activeSession.clientId ? { clientId: activeSession.clientId } : {}), - workspaceCwd: activeSession.workspaceCwd, + sessionContext: activeProductSessionContext, + workspaceCwd: activeWorkspaceScoped + ? activeProductSessionContext.cwd + : undefined, + standaloneSession: + activeProductSessionContext.kind === 'standalone' + ? getStandaloneConnectionState(activeSession.session) + : undefined, // A fulfilled supported-commands fetch is authoritative even when // it returns an empty list: fall back to the preserved // `current.commands` only when the fetch was skipped or failed @@ -2452,7 +2720,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { contextWindow: configSnapshotCurrent ? (sessionContextWindow ?? current.contextWindow) : current.contextWindow, - providers: providers ?? current.providers, + providers: activeWorkspaceScoped + ? (providers ?? current.providers) + : undefined, supportedCommands: supportedCommands ?? current.supportedCommands, context: configSnapshotCurrent ? (context ?? current.context) @@ -2473,9 +2743,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) : (current.goalState ?? goalStateFallback), gitBranch: - gitResult.status === 'fulfilled' + activeWorkspaceScoped && gitResult.status === 'fulfilled' ? gitBranch - : current.gitBranch, + : activeWorkspaceScoped + ? current.gitBranch + : undefined, + gitStatus: activeWorkspaceScoped ? current.gitStatus : undefined, capabilities: capabilities ?? current.capabilities, loadingTranscript: undefined, catchingUp: @@ -2645,11 +2918,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { break; } } - bumpWorkspaceEventSignals( - uiEvents, - setWorkspaceEventSignals, - activeSession.workspaceCwd, - ); + if (activeWorkspaceScoped) { + bumpWorkspaceEventSignals( + uiEvents, + setWorkspaceEventSignals, + activeProductSessionContext.cwd, + ); + } if (uiEvents.length > 0) { const hasGenerationSignal = hasActiveGenerationSignal(uiEvents); setPromptStatus((current) => @@ -2954,7 +3229,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setPromptStatus('idle'); clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); setConnection((current) => ({ - ...current, + ...clearNonWorkspaceSessionState(current), status: 'disconnected', sessionId: undefined, context: undefined, @@ -3029,16 +3304,75 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const message = error instanceof Error ? error.message : String(error); const errorStatus = extractHttpStatus(error); + if ( + activeSessionContextRef.current?.kind === 'standalone' && + !standaloneCreateAttempted + ) { + setConnection((current) => ({ + ...current, + standaloneSession: { + ...current.standaloneSession, + errorCode: getDaemonErrorCode(error), + }, + })); + } + if (standaloneCreateAttempted) { + standaloneCreateAttempted = false; + manualSessionClearRef.current = true; + const outcomeUnknown = + error instanceof DaemonStandaloneCreationOutcomeUnknownError; + setConnection((current) => ({ + ...getConnectionAfterSessionClear( + current, + current.sessionId, + false, + ), + status: 'error', + ...(outcomeUnknown ? { sessionId: error.sessionId } : {}), + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: outcomeUnknown + ? { + creationRecovery: error.recovery, + errorCode: getDaemonErrorCode(error.originalError), + } + : { + errorCode: + error instanceof DaemonCapabilityMissingError + ? error.capability + : getDaemonErrorCode(error), + }, + error: message, + errorStatus: resolveConnectionErrorStatus( + outcomeUnknown + ? extractHttpStatus(error.originalError) + : errorStatus, + current.errorStatus, + ), + missingSession: false, + loadingTranscript: undefined, + catchingUp: undefined, + })); + return; + } const pendingLoad = pendingSessionLoadRef.current; + const pendingLoadContextMatches = + pendingLoad === undefined || + restoreSessionContextMatches( + pendingLoad.sessionContext, + activeSessionContextRef.current, + ); const restoreRetryDelayMs = getRestoreInProgressRetryDelayMs(error); const pendingLoadMatches = - pendingLoad === undefined || - pendingLoad.sessionId === restoreSessionId; + pendingLoadContextMatches && + (pendingLoad === undefined || + pendingLoad.sessionId === restoreSessionId); if ( autoReconnect && loadingRequestedSession && ((restoreRetryDelayMs !== undefined && pendingLoadMatches) || (pendingLoad?.sessionId === restoreSessionId && + pendingLoadContextMatches && isClosingSessionLoadError( error, !capabilities?.features.includes(CLIENT_IDENTITY_FEATURE), @@ -3080,6 +3414,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } if ( pendingLoad && + pendingLoadContextMatches && (pendingLoad.sessionId === restoreSessionId || pendingLoad.sessionId === reconnectSessionId) ) { @@ -3095,6 +3430,38 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } pendingLoad.reject(error); } + if ( + productContextFailure || + error instanceof DaemonCapabilityMissingError || + (session === undefined && + activeSessionContextRef.current?.kind !== 'workspace' && + activeSessionContextRef.current !== undefined && + isDaemonErrorExplicitlyNonRetryable(error)) + ) { + setConnection((current) => ({ + ...current, + status: 'error', + error: message, + errorStatus: resolveConnectionErrorStatus( + errorStatus, + current.errorStatus, + ), + standaloneSession: + activeSessionContextRef.current?.kind === 'standalone' + ? { + ...current.standaloneSession, + errorCode: + error instanceof DaemonCapabilityMissingError + ? error.capability + : getDaemonErrorCode(error), + } + : undefined, + missingSession: false, + loadingTranscript: undefined, + catchingUp: undefined, + })); + return; + } if (isAuthFailure || isTerminal) { // Auth failures (401/403) and terminal session errors (404/410) // must clear the session — the server-side state is gone or @@ -3103,7 +3470,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { sessionRef.current = undefined; if (isAuthFailure) { setConnection((current) => ({ - ...current, + ...clearNonWorkspaceSessionState(current), status: 'error', sessionId: undefined, context: undefined, @@ -3132,7 +3499,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { message, ); setConnection((current) => ({ - ...current, + ...clearNonWorkspaceSessionState(current), status: 'disconnected', sessionId: undefined, context: undefined, @@ -3224,6 +3591,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { })); } + if (disposed || abort.signal.aborted) return; if (!autoReconnect) { sessionRef.current = undefined; setConnection((current) => ({ @@ -3268,7 +3636,19 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { session === undefined && sessionRef.current === undefined; const keepSessionForNextEffect = ownsCurrentSession && - session === skipNextCleanupDetachSessionRef.current; + (session === skipNextCleanupDetachSessionRef.current || + sessionContextResolutionErrorRef.current !== undefined); + if ( + ownsCurrentSession && + sessionContextResolutionErrorRef.current !== undefined + ) { + contextErrorPreservedSessionRef.current = session; + contextErrorPreservedProductContextRef.current = + activeSessionContextRef.current; + } else if (contextErrorPreservedSessionRef.current === session) { + contextErrorPreservedSessionRef.current = undefined; + contextErrorPreservedProductContextRef.current = undefined; + } const isUnmounting = !mountedRef.current; if (ownsCurrentSession || ownsEmptyState) { // A same-attachment effect restart must flush events already yielded by @@ -3321,7 +3701,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { autoReconnect, resolvedBaseUrl, resolvedToken, - sessionEffectWorkspaceCwd, + sessionEffectContext, + sessionContextResolutionError, modelServiceId, sessionScope, maxQueued, @@ -3329,7 +3710,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { maxRetainedBytes, store, restoreSessionId, - restoreWorkspaceCwd, + restoreSessionContext, restoreMode, restoreSessionNonce, attachSessionNonce, @@ -3457,7 +3838,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection((current) => current.sessionId === session.sessionId ? { - ...current, + ...(authFailure || missingSession + ? clearNonWorkspaceSessionState(current) + : current), status: authFailure ? 'error' : 'disconnected', error: effectiveMessage, errorStatus: resolveConnectionErrorStatus( @@ -3575,6 +3958,32 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { requestClientId, ); }, + createDetachedStandaloneSession: (overrides) => { + const client = + workspaceClientRef.current ?? + new DaemonClient({ + baseUrl: resolvedBaseUrl!, + token: resolvedToken, + }); + const approvalMode = resolveStandaloneApprovalMode( + overrides?.approvalMode ?? + createSessionRequestRef.current?.approvalMode, + ); + return DaemonSessionClient.createStandalone(client, { + ...(createSessionRequestRef.current?.modelServiceId !== undefined + ? { + modelServiceId: + createSessionRequestRef.current.modelServiceId, + } + : {}), + ...(approvalMode !== undefined ? { approvalMode } : {}), + }); + }, + getDefaultSessionContext: () => { + const error = sessionContextResolutionErrorRef.current; + if (error !== undefined) throw new Error(error); + return resolvedSessionContextRef.current; + }, getConnection: () => connectionRef.current, addNotice, setConnection, @@ -3582,7 +3991,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setPromptStatus(update); }, setRestoreSessionId, - setRestoreWorkspaceCwd, + setRestoreSessionContext, setRestoreMode, setRestoreSessionNonce, setAttachSessionNonce, @@ -3876,44 +4285,48 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const lastHandledSessionIdRef = useRef< string | undefined | typeof UNHANDLED_SESSION >(UNHANDLED_SESSION); - const lastHandledWorkspaceRef = useRef(undefined); + const lastHandledSessionContextRef = useRef< + DaemonProductSessionContext | undefined + >(undefined); const lastHandledClientIdRef = useRef(undefined); useEffect(() => { - const targetWorkspaceCwd = - resolvedWorkspaceCwd ?? + if (sessionContextResolutionError) return; + const targetSessionContext = + resolvedSessionContext ?? // A failed controlled load leaves the target's workspace on the // connection for error rendering; never feed it back into the next // workspace-less switch. (connectionRef.current.error ? undefined - : connectionRef.current.workspaceCwd); + : connectionRef.current.sessionContext); if ( lastHandledSessionIdRef.current === sessionId && - normalizeWorkspaceIdentity(lastHandledWorkspaceRef.current) === - normalizeWorkspaceIdentity(targetWorkspaceCwd) && + sessionContextKey(lastHandledSessionContextRef.current) === + sessionContextKey(targetSessionContext) && lastHandledClientIdRef.current === clientId ) { return; } lastHandledSessionIdRef.current = sessionId; - lastHandledWorkspaceRef.current = targetWorkspaceCwd; + lastHandledSessionContextRef.current = targetSessionContext; lastHandledClientIdRef.current = clientId; const currentSessionId = connectionRef.current.sessionId; - const currentWorkspaceCwd = connectionRef.current.workspaceCwd; if ( sessionId === currentSessionId && - normalizeWorkspaceIdentity(targetWorkspaceCwd) === - normalizeWorkspaceIdentity(currentWorkspaceCwd) + sessionContextKey(targetSessionContext) === + sessionContextKey(connectionRef.current.sessionContext) && + !connectionRef.current.standaloneSession?.creationRecovery ) { return; } + setRestoreSessionContext(targetSessionContext); const request = sessionId ? actions.loadSession(sessionId, { - ...(targetWorkspaceCwd !== undefined - ? { workspaceCwd: targetWorkspaceCwd } + ...(targetSessionContext !== undefined + ? { sessionContext: targetSessionContext } : {}), }) : currentSessionId @@ -3928,7 +4341,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { error, ); }); - }, [actions, clientId, resolvedWorkspaceCwd, sessionId]); + }, [ + actions, + clientId, + resolvedSessionContext, + sessionContextResolutionError, + sessionId, + ]); const ownerGuardValue = useMemo( () => ({ diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 4af5cdab8f6..477f2f19346 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { DaemonHttpError, DaemonPendingPromptLimitError, + DaemonStandaloneCreationOutcomeUnknownError, DaemonTransportClosedError, type DaemonCapabilities, type DaemonSessionClient, @@ -10,11 +11,13 @@ import { import { createDaemonSessionActions, getConnectionAfterSessionClear, + getWorkspaceModelsAfterSessionClear, resolveSessionRestoreTimeouts, } from './actions'; import type { ActivePrompt, DaemonConnectionState, + DaemonProductSessionContext, PendingSessionLoad, SettledPrompt, } from './types'; @@ -213,6 +216,42 @@ describe('getConnectionAfterSessionClear', () => { error: undefined, }); }); + + it('drops workspace and session previews when clearing a standalone session', () => { + const current: DaemonConnectionState = { + status: 'connected', + sessionId: 'standalone-a', + sessionContext: { kind: 'standalone' }, + commands: [commandInfo('old-command')], + skills: ['old-skill'], + models: [{ id: 'old-model', label: 'Old model' }], + providers: { + v: 1, + workspaceCwd: '/primary', + initialized: true, + providers: [], + }, + gitBranch: 'main', + gitStatus: {} as never, + standaloneSession: { + projectlessOutputDirectory: '/output/standalone-a', + }, + }; + const next = getConnectionAfterSessionClear(current, 'standalone-a'); + + expect(next).toMatchObject({ + status: 'connected', + sessionContext: { kind: 'standalone' }, + }); + expect(next).not.toHaveProperty('commands'); + expect(next).not.toHaveProperty('skills'); + expect(next).not.toHaveProperty('models'); + expect(next).not.toHaveProperty('providers'); + expect(next).not.toHaveProperty('gitBranch'); + expect(next).not.toHaveProperty('gitStatus'); + expect(next).not.toHaveProperty('standaloneSession'); + expect(getWorkspaceModelsAfterSessionClear(current)).toBeUndefined(); + }); }); /** @@ -418,7 +457,464 @@ describe('createDaemonSessionActions', () => { expect(createDetachedSession).toHaveBeenCalledOnce(); expect(sessionRef.current).toBe(nextSession); - expect(getConnection()).toMatchObject({ sessionId: 'session-b' }); + expect(getConnection()).toMatchObject({ + sessionId: 'session-b', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + }); + }); + + it('publishes the daemon-reported workspace after detached create', async () => { + const nextSession = createMockSession('session-b'); + nextSession.workspaceCwd = '/private/canonical-workspace'; + const { actions, getConnection } = createActionsHarness({ + connection: { status: 'connected' }, + createDetachedSession: vi.fn(async () => nextSession), + }); + + await actions.createSession({ workspaceCwd: '/workspace-alias' }); + + expect(getConnection()).toMatchObject({ + sessionContext: { + kind: 'workspace', + cwd: '/private/canonical-workspace', + }, + workspaceCwd: '/private/canonical-workspace', + }); + }); + + it('clears standalone state after detached workspace create', async () => { + const nextSession = createMockSession('workspace-b'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'standalone' }, + standaloneSession: { + projectlessOutputDirectory: '/output/old', + workingDirectory: { state: 'ready' }, + }, + }, + createDetachedSession: vi.fn(async () => nextSession), + }); + + await actions.createSession({ workspaceCwd: '/workspace' }); + + expect(getConnection()).toMatchObject({ + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + }); + expect(getConnection().standaloneSession).toBeUndefined(); + }); + + it('uses the standalone create path without a workspace fallback', async () => { + const nextSession = createMockSession('standalone-b'); + Object.assign(nextSession, { + session: { + sessionId: 'standalone-b', + workspaceCwd: '/private/standalone-b', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/standalone-b', + workingDirectory: { state: 'ready' }, + }, + }); + const createDetachedSession = vi.fn(); + const createDetachedStandaloneSession = vi.fn(async () => nextSession); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'standalone' }, + }, + createDetachedSession, + createDetachedStandaloneSession, + }); + + await expect(actions.createSession({ approvalMode: 'yolo' })).resolves.toBe( + nextSession, + ); + + expect(createDetachedStandaloneSession).toHaveBeenCalledWith({ + approvalMode: 'yolo', + }); + expect(createDetachedSession).not.toHaveBeenCalled(); + expect(getConnection()).toMatchObject({ + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + projectlessOutputDirectory: '/output/standalone-b', + }, + }); + }); + + it('uses the default standalone context for a contextless create', async () => { + const nextSession = createMockSession('standalone-default'); + Object.assign(nextSession, { + session: { + sessionId: 'standalone-default', + workspaceCwd: '/private/standalone-default', + sourceType: 'standalone', + context: { kind: 'standalone' }, + workingDirectory: { state: 'ready' }, + }, + }); + const createDetachedSession = vi.fn(); + const createDetachedStandaloneSession = vi.fn(async () => nextSession); + const { actions } = createActionsHarness({ + connection: { status: 'connected' }, + createDetachedSession, + createDetachedStandaloneSession, + getDefaultSessionContext: () => ({ kind: 'standalone' }), + }); + + await expect(actions.createSession()).resolves.toBe(nextSession); + expect(createDetachedStandaloneSession).toHaveBeenCalledOnce(); + expect(createDetachedSession).not.toHaveBeenCalled(); + }); + + it('returns a detached standalone result without replacing an active session', async () => { + const activeSession = createMockSession('workspace-a'); + const nextSession = createMockSession('standalone-b'); + const standaloneRecord = { + sessionId: 'standalone-b', + workspaceCwd: '/private/standalone-b', + sourceType: 'standalone', + context: { kind: 'standalone' as const }, + workingDirectory: { state: 'ready' as const }, + }; + Object.assign(nextSession, { session: standaloneRecord }); + const createDetachedStandaloneSession = vi.fn(async () => nextSession); + const connection: DaemonConnectionState = { + status: 'connected', + sessionId: 'workspace-a', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + }; + const { actions, getConnection } = createActionsHarness({ + connection, + session: activeSession, + createDetachedStandaloneSession, + }); + + await expect( + actions.createSession({ + sessionContext: { kind: 'standalone' }, + approvalMode: 'yolo', + }), + ).resolves.toBe(standaloneRecord); + expect(createDetachedStandaloneSession).toHaveBeenCalledWith({ + approvalMode: 'yolo', + }); + expect(activeSession.client.createOrAttachSession).not.toHaveBeenCalled(); + expect(getConnection()).toEqual(connection); + }); + + it('clears workspace metadata before publishing a fresh standalone session', async () => { + const nextSession = createMockSession('standalone-b'); + Object.assign(nextSession, { + session: { + sessionId: 'standalone-b', + workspaceCwd: '/private/standalone-b', + sourceType: 'standalone', + context: { kind: 'standalone' }, + workingDirectory: { state: 'ready' }, + }, + }); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + commands: [commandInfo('workspace-command')], + skills: ['workspace-skill'], + models: [{ id: 'workspace-model', label: 'Workspace model' }], + providers: { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + providers: [], + }, + gitBranch: 'main', + gitStatus: {} as never, + }, + createDetachedStandaloneSession: vi.fn(async () => nextSession), + }); + + await actions.clearSession(); + await actions.createSession({ sessionContext: { kind: 'standalone' } }); + + expect(getConnection().sessionContext).toEqual({ kind: 'standalone' }); + expect(getConnection().workspaceCwd).toBeUndefined(); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().providers).toBeUndefined(); + expect(getConnection().gitBranch).toBeUndefined(); + expect(getConnection().gitStatus).toBeUndefined(); + }); + + it('surfaces standalone create recovery without retrying create', async () => { + const originalError = new DaemonHttpError( + 503, + { code: 'standalone_session_creating' }, + 'outcome unknown', + ); + const error = new DaemonStandaloneCreationOutcomeUnknownError( + '019cf000-0000-7000-8000-000000000001', + { + state: 'creating', + sessionId: '019cf000-0000-7000-8000-000000000001', + }, + originalError, + ); + const createDetachedStandaloneSession = vi.fn(async () => { + throw error; + }); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'standalone' }, + }, + createDetachedStandaloneSession, + }); + + await expect(actions.createSession()).rejects.toBe(error); + + expect(createDetachedStandaloneSession).toHaveBeenCalledOnce(); + expect(getConnection()).toMatchObject({ + status: 'error', + sessionId: '019cf000-0000-7000-8000-000000000001', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + creationRecovery: { + state: 'creating', + sessionId: '019cf000-0000-7000-8000-000000000001', + }, + errorCode: 'standalone_session_creating', + }, + errorStatus: 503, + }); + }); + + it('publishes standalone recovery after a workspace predecessor is cleared', async () => { + const originalError = new DaemonHttpError( + 503, + { code: 'standalone_session_creating' }, + 'outcome unknown', + ); + const error = new DaemonStandaloneCreationOutcomeUnknownError( + '019cf000-0000-7000-8000-000000000003', + { + state: 'creating', + sessionId: '019cf000-0000-7000-8000-000000000003', + }, + originalError, + ); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + commands: [commandInfo('workspace-command')], + skills: ['workspace-skill'], + models: [{ id: 'workspace-model', label: 'Workspace model' }], + currentModel: 'workspace-model', + providers: { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + providers: [], + }, + gitBranch: 'main', + gitStatus: {} as never, + }, + createDetachedStandaloneSession: vi.fn(async () => { + throw error; + }), + }); + + await actions.clearSession(); + await expect( + actions.createSession({ sessionContext: { kind: 'standalone' } }), + ).rejects.toBe(error); + expect(getConnection()).toMatchObject({ + status: 'error', + sessionContext: { kind: 'standalone' }, + sessionId: '019cf000-0000-7000-8000-000000000003', + workspaceCwd: undefined, + standaloneSession: { + creationRecovery: { + state: 'creating', + sessionId: '019cf000-0000-7000-8000-000000000003', + }, + }, + }); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().currentModel).toBeUndefined(); + expect(getConnection().providers).toBeUndefined(); + expect(getConnection().gitBranch).toBeUndefined(); + expect(getConnection().gitStatus).toBeUndefined(); + }); + + it('does not replace an active workspace with detached standalone recovery', async () => { + const activeSession = createMockSession('workspace-a'); + const originalError = new DaemonHttpError( + 503, + { code: 'standalone_session_creating' }, + 'outcome unknown', + ); + const error = new DaemonStandaloneCreationOutcomeUnknownError( + '019cf000-0000-7000-8000-000000000002', + { + state: 'creating', + sessionId: '019cf000-0000-7000-8000-000000000002', + }, + originalError, + ); + const createDetachedStandaloneSession = vi.fn(async () => { + throw error; + }); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'workspace-a', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + }, + session: activeSession, + createDetachedStandaloneSession, + }); + + await expect( + actions.createSession({ sessionContext: { kind: 'standalone' } }), + ).rejects.toBe(error); + + expect(getConnection()).toEqual({ + status: 'connected', + sessionId: 'workspace-a', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + }); + }); + + it('rejects create in the Live session context', async () => { + const createDetachedSession = vi.fn(); + const createDetachedStandaloneSession = vi.fn(); + const { actions } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'live' }, + }, + createDetachedSession, + createDetachedStandaloneSession, + }); + + await expect(actions.createSession()).rejects.toThrow( + 'Live session context does not support create', + ); + expect(createDetachedSession).not.toHaveBeenCalled(); + expect(createDetachedStandaloneSession).not.toHaveBeenCalled(); + }); + + it('rejects newSession in the Live context without changing state', async () => { + const connection: DaemonConnectionState = { + status: 'connected', + sessionId: 'live-a', + sessionContext: { kind: 'live' }, + displayName: 'Live A', + }; + const { actions, getConnection } = createActionsHarness({ connection }); + + await expect(actions.newSession()).rejects.toThrow( + 'Live session context does not support create', + ); + expect(getConnection()).toEqual(connection); + }); + + it.each([ + { sourceType: 'default' }, + { worktree: { slug: 'feature' } }, + { branch: { name: 'feature' } }, + ])( + 'rejects workspace-only create options for standalone sessions: $sourceType$worktree.slug$branch.name', + async (workspaceOnlyOption) => { + const createDetachedSession = vi.fn(); + const createDetachedStandaloneSession = vi.fn(); + const { actions } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'standalone' }, + }, + createDetachedSession, + createDetachedStandaloneSession, + }); + + await expect(actions.createSession(workspaceOnlyOption)).rejects.toThrow( + 'Standalone session creation does not support sourceType, worktree, or branch options', + ); + expect(createDetachedSession).not.toHaveBeenCalled(); + expect(createDetachedStandaloneSession).not.toHaveBeenCalled(); + }, + ); + + it('does not apply the generic create timeout to standalone create', async () => { + vi.useFakeTimers(); + try { + const deferred = createDeferred(); + const nextSession = createMockSession('standalone-b'); + Object.assign(nextSession, { + session: { + sessionId: 'standalone-b', + workspaceCwd: '/private/standalone-b', + sourceType: 'standalone', + context: { kind: 'standalone' }, + workingDirectory: { state: 'ready' }, + }, + }); + const { actions } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'standalone' }, + }, + createDetachedStandaloneSession: vi.fn(() => deferred.promise), + }); + + const pending = actions.createSession(); + let settled = false; + void pending.finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(30_001); + expect(settled).toBe(false); + deferred.resolve(nextSession as unknown as DaemonSessionClient); + await expect(pending).resolves.toBe(nextSession); + } finally { + vi.useRealTimers(); + } + }); + + it('applies the generic create timeout to workspace create', async () => { + vi.useFakeTimers(); + try { + const deferred = createDeferred(); + const { actions } = createActionsHarness({ + connection: { + status: 'connected', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + }, + createDetachedSession: vi.fn(() => deferred.promise), + }); + + const pending = actions.createSession(); + await Promise.all([ + expect(pending).rejects.toThrow('Create session timed out'), + vi.advanceTimersByTimeAsync(30_000), + ]); + } finally { + vi.useRealTimers(); + } }); it('forwards options.workspaceCwd to the detached create branch', async () => { @@ -580,6 +1076,230 @@ describe('createDaemonSessionActions', () => { expect(getConnection().goalState).toBeUndefined(); }); + it('drops workspace previews when switching to a standalone target', () => { + const existingSession = createMockSession('session-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'session-a', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + commands: [commandInfo('workspace-command')], + skills: ['workspace-skill'], + models: [{ id: 'workspace-model', label: 'Workspace model' }], + providers: { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + providers: [], + }, + gitBranch: 'main', + }, + session: existingSession, + }); + + void actions + .loadSession('standalone-b', { + sessionContext: { kind: 'standalone' }, + }) + .catch(() => undefined); + + expect(getConnection()).toMatchObject({ + status: 'connecting', + sessionId: 'standalone-b', + sessionContext: { kind: 'standalone' }, + loadingTranscript: true, + }); + expect(getConnection().workspaceCwd).toBeUndefined(); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().providers).toBeUndefined(); + expect(getConnection().gitBranch).toBeUndefined(); + }); + + it('drops stale previews when switching between standalone sessions', () => { + const existingSession = createMockSession('standalone-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'standalone-a', + sessionContext: { kind: 'standalone' }, + commands: [commandInfo('standalone-command')], + skills: ['standalone-skill'], + models: [{ id: 'standalone-model', label: 'Standalone model' }], + currentModel: 'standalone-model', + currentMode: 'yolo', + contextWindow: 32_000, + }, + session: existingSession, + }); + + void actions + .loadSession('standalone-b', { + sessionContext: { kind: 'standalone' }, + }) + .catch(() => undefined); + + expect(getConnection()).toMatchObject({ + status: 'connecting', + sessionId: 'standalone-b', + sessionContext: { kind: 'standalone' }, + }); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().currentModel).toBeUndefined(); + expect(getConnection().currentMode).toBeUndefined(); + expect(getConnection().contextWindow).toBeUndefined(); + }); + + it('drops stale previews when switching between Live sessions', () => { + const existingSession = createMockSession('live-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'live-a', + sessionContext: { kind: 'live' }, + commands: [commandInfo('live-command')], + skills: ['live-skill'], + models: [{ id: 'live-model', label: 'Live model' }], + currentModel: 'live-model', + }, + session: existingSession, + }); + + void actions + .loadSession('live-b', { sessionContext: { kind: 'live' } }) + .catch(() => undefined); + + expect(getConnection()).toMatchObject({ + status: 'connecting', + sessionId: 'live-b', + sessionContext: { kind: 'live' }, + }); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().currentModel).toBeUndefined(); + }); + + it('drops standalone previews when switching to a workspace target', () => { + const existingSession = createMockSession('standalone-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'standalone-a', + sessionContext: { kind: 'standalone' }, + commands: [commandInfo('standalone-command')], + skills: ['standalone-skill'], + models: [{ id: 'standalone-model', label: 'Standalone model' }], + currentModel: 'standalone-model', + currentMode: 'yolo', + contextWindow: 32_000, + }, + session: existingSession, + }); + + void actions + .loadSession('workspace-b', { + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + }) + .catch(() => undefined); + + expect(getConnection()).toMatchObject({ + status: 'connecting', + sessionId: 'workspace-b', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + loadingTranscript: true, + }); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().currentModel).toBeUndefined(); + expect(getConnection().currentMode).toBeUndefined(); + expect(getConnection().contextWindow).toBeUndefined(); + }); + + it('drops workspace previews when switching to a Live target', () => { + const existingSession = createMockSession('workspace-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'workspace-a', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + commands: [commandInfo('workspace-command')], + skills: ['workspace-skill'], + models: [{ id: 'workspace-model', label: 'Workspace model' }], + currentModel: 'workspace-model', + contextWindow: 32_000, + providers: { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + providers: [], + }, + gitBranch: 'main', + }, + session: existingSession, + }); + + void actions + .loadSession('live-b', { sessionContext: { kind: 'live' } }) + .catch(() => undefined); + + expect(getConnection()).toMatchObject({ + status: 'connecting', + sessionId: 'live-b', + sessionContext: { kind: 'live' }, + }); + expect(getConnection().workspaceCwd).toBeUndefined(); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().currentModel).toBeUndefined(); + expect(getConnection().contextWindow).toBeUndefined(); + expect(getConnection().providers).toBeUndefined(); + expect(getConnection().gitBranch).toBeUndefined(); + }); + + it('drops Live previews when switching to a workspace target', () => { + const existingSession = createMockSession('live-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'live-a', + sessionContext: { kind: 'live' }, + commands: [commandInfo('live-command')], + skills: ['live-skill'], + models: [{ id: 'live-model', label: 'Live model' }], + currentModel: 'live-model', + contextWindow: 32_000, + }, + session: existingSession, + }); + + void actions + .loadSession('workspace-b', { + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + }) + .catch(() => undefined); + + expect(getConnection()).toMatchObject({ + status: 'connecting', + sessionId: 'workspace-b', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + workspaceCwd: '/workspace', + }); + expect(getConnection().commands).toBeUndefined(); + expect(getConnection().skills).toBeUndefined(); + expect(getConnection().models).toBeUndefined(); + expect(getConnection().currentModel).toBeUndefined(); + expect(getConnection().contextWindow).toBeUndefined(); + }); + it('carries the daemon-advertised restore budget into the load request', async () => { // Live path for the whole chain: advertised capability -> connection -> // resolveSessionRestoreTimeouts -> pending load. Dropping the capabilities @@ -747,24 +1467,53 @@ describe('createDaemonSessionActions', () => { }); it('keeps the active workspace when a session load omits one', () => { - const setRestoreWorkspaceCwd = vi.fn(); + const setRestoreSessionContext = vi.fn(); const { actions } = createActionsHarness({ connection: { status: 'connected', workspaceCwd: '/workspace/secondary', }, - setRestoreWorkspaceCwd, + setRestoreSessionContext, }); void actions.loadSession('session-b').catch(() => undefined); - expect(setRestoreWorkspaceCwd).toHaveBeenCalledWith('/workspace/secondary'); + expect(setRestoreSessionContext).toHaveBeenCalledWith({ + kind: 'workspace', + cwd: '/workspace/secondary', + }); + }); + + it('uses the default standalone context after a failed connection', () => { + const setRestoreSessionContext = vi.fn(); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'error', + error: 'previous load failed', + sessionContext: { kind: 'workspace', cwd: '/failed-target' }, + workspaceCwd: '/failed-target', + }, + getDefaultSessionContext: () => ({ kind: 'standalone' }), + setRestoreSessionContext, + }); + + void actions.loadSession('standalone-b').catch(() => undefined); + + expect(setRestoreSessionContext).toHaveBeenCalledWith({ + kind: 'standalone', + }); + expect(getConnection()).toMatchObject({ + status: 'connecting', + sessionId: 'standalone-b', + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + }); }); it('does not inherit a failed load target workspace on the next switch', async () => { const existingSession = createMockSession('session-a'); existingSession.workspaceCwd = '/work/a'; - const setRestoreWorkspaceCwd = vi.fn(); + const setRestoreSessionContext = vi.fn(); const { actions, getConnection, pendingSessionLoadRef } = createActionsHarness({ connection: { @@ -773,7 +1522,7 @@ describe('createDaemonSessionActions', () => { workspaceCwd: '/work/a', }, session: existingSession, - setRestoreWorkspaceCwd, + setRestoreSessionContext, }); const first = actions.loadSession('session-b', { @@ -801,7 +1550,7 @@ describe('createDaemonSessionActions', () => { // ...but the next workspace-less switch must not inherit it. void actions.loadSession('session-c'); - expect(setRestoreWorkspaceCwd).toHaveBeenLastCalledWith(undefined); + expect(setRestoreSessionContext).toHaveBeenLastCalledWith(undefined); }); it('does not roll back the workspace for a superseded load', async () => { @@ -839,17 +1588,20 @@ describe('createDaemonSessionActions', () => { }); it('forwards the workspace when resuming a session', () => { - const setRestoreWorkspaceCwd = vi.fn(); + const setRestoreSessionContext = vi.fn(); const { actions } = createActionsHarness({ connection: { status: 'connected', workspaceCwd: '/workspace/primary' }, - setRestoreWorkspaceCwd, + setRestoreSessionContext, }); void actions .resumeSession('session-b', { workspaceCwd: '/workspace/secondary' }) .catch(() => undefined); - expect(setRestoreWorkspaceCwd).toHaveBeenCalledWith('/workspace/secondary'); + expect(setRestoreSessionContext).toHaveBeenCalledWith({ + kind: 'workspace', + cwd: '/workspace/secondary', + }); }); it('clears transcript loading when a session switch fails', async () => { @@ -2611,13 +3363,15 @@ function createActionsHarness( clearLiveJournalRepair?: ReturnType; connection?: DaemonConnectionState; createDetachedSession?: ReturnType; + createDetachedStandaloneSession?: ReturnType; manualSessionClearRef?: { current: boolean }; pendingSessionLoadRef?: { current: PendingSessionLoad | undefined }; restartEventStream?: ReturnType; session?: ReturnType; setAttachSessionNonce?: ReturnType; setRestoreSessionId?: ReturnType; - setRestoreWorkspaceCwd?: ReturnType; + setRestoreSessionContext?: ReturnType; + getDefaultSessionContext?: () => DaemonProductSessionContext | undefined; } = {}, ) { let connection: DaemonConnectionState = opts.connection ?? { @@ -2663,6 +3417,15 @@ function createActionsHarness( 'detached-session', ) as unknown as DaemonSessionClient, )) as () => Promise, + createDetachedStandaloneSession: (opts.createDetachedStandaloneSession ?? + vi.fn( + async () => + createMockSession( + 'detached-standalone-session', + ) as unknown as DaemonSessionClient, + )) as () => Promise, + getDefaultSessionContext: + opts.getDefaultSessionContext ?? (() => undefined), getConnection: () => connection, hasSessionActivePrompt: () => false, resetCurrentSessionActivePrompt: vi.fn(), @@ -2674,7 +3437,7 @@ function createActionsHarness( }, setPromptStatus: vi.fn(), setRestoreSessionId: opts.setRestoreSessionId ?? vi.fn(), - setRestoreWorkspaceCwd: opts.setRestoreWorkspaceCwd ?? vi.fn(), + setRestoreSessionContext: opts.setRestoreSessionContext ?? vi.fn(), setRestoreMode: vi.fn(), setRestoreSessionNonce: vi.fn(), setAttachSessionNonce: opts.setAttachSessionNonce ?? vi.fn(), @@ -2710,6 +3473,7 @@ function createMockSession( persisted: false, })), listWorkspaceSessions: vi.fn(), + listStandaloneSessions: vi.fn(), closeSession: vi.fn(), removeSessionAttachment: vi.fn(async () => true), }, diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 3ff439e353f..d8539191ec9 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -35,6 +35,7 @@ import type { import { DaemonHttpError, DaemonPendingPromptLimitError, + DaemonStandaloneCreationOutcomeUnknownError, DaemonTransportClosedError, isDaemonTurnError, isStaleBranchPointError, @@ -64,6 +65,12 @@ import { getPersistedClientId, persistStableClientId, } from './clientLifecycle.js'; +import { + getDaemonErrorCode, + getStandaloneConnectionState, + resolveActionSessionContext, + sessionContextKey, +} from './session-context.js'; import type { ActivePrompt, AddDaemonSessionNotice, @@ -74,6 +81,7 @@ import type { DaemonSessionActions, SettledPrompt, PendingSessionLoad, + DaemonProductSessionContext, } from './types.js'; interface RefBox { @@ -165,10 +173,6 @@ 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; @@ -189,6 +193,10 @@ export interface CreateDaemonSessionActionsArgs { 'approvalMode' | 'sourceType' | 'worktree' | 'branch' >, ) => Promise; + createDetachedStandaloneSession: ( + overrides?: Pick, + ) => Promise; + getDefaultSessionContext: () => DaemonProductSessionContext | undefined; getConnection: () => DaemonConnectionState; hasSessionActivePrompt: () => boolean; resetCurrentSessionActivePrompt: () => void; @@ -197,7 +205,9 @@ export interface CreateDaemonSessionActionsArgs { setConnection: Dispatch>; setPromptStatus: Dispatch>; setRestoreSessionId: Dispatch>; - setRestoreWorkspaceCwd: Dispatch>; + setRestoreSessionContext: Dispatch< + SetStateAction + >; setRestoreMode: Dispatch>; setRestoreSessionNonce: Dispatch>; setAttachSessionNonce: Dispatch>; @@ -208,6 +218,12 @@ export interface CreateDaemonSessionActionsArgs { export function getWorkspaceModelsAfterSessionClear( current: DaemonConnectionState, ): DaemonConnectionState['models'] { + if ( + current.sessionContext !== undefined && + current.sessionContext.kind !== 'workspace' + ) { + return undefined; + } return current.providers ? mapProviderStatus(current.providers).models : current.models; @@ -216,6 +232,8 @@ export function getWorkspaceModelsAfterSessionClear( export function getConnectionAfterSessionClear( current: DaemonConnectionState, clearedSessionId: string | undefined, + preserveWorkspaceMetadata = current.sessionContext === undefined || + current.sessionContext.kind === 'workspace', ): DaemonConnectionState { const next = { ...current }; if (!clearedSessionId || current.sessionId === clearedSessionId) { @@ -225,21 +243,34 @@ export function getConnectionAfterSessionClear( delete next.tokenUsage; delete next.tokenCount; delete next.goalState; + delete next.standaloneSession; // Drop the session-scoped raw snapshots (both carry the cleared // sessionId), which also makes the effect's canReuseSessionMetadata // check refetch fresh data for the next session. delete next.supportedCommands; delete next.context; delete next.reasoning; - next.models = getWorkspaceModelsAfterSessionClear(current); - // Keep `commands`/`skills`: they are workspace-scoped (skills, custom, - // MCP-prompt and workflow slash commands all live at the workspace/config - // level, not the session), so they stay valid after the session is - // cleared. Clearing starts a fresh deferred session that is not created - // until the first prompt (#6066); preserving these keeps skill-backed - // slash commands like /review autocompleting in that window — the same - // guarantee #6153 added for the initial deferred connect. The next - // session's available_commands_update refreshes them once it lands. + if (preserveWorkspaceMetadata) { + // Keep `commands`/`skills`: they are workspace-scoped (skills, custom, + // MCP-prompt and workflow slash commands all live at the workspace/config + // level, not the session), so they stay valid after the session is + // cleared. Clearing starts a fresh deferred session that is not created + // until the first prompt (#6066); preserving these keeps skill-backed + // slash commands like /review autocompleting in that window — the same + // guarantee #6153 added for the initial deferred connect. The next + // session's available_commands_update refreshes them once it lands. + next.models = getWorkspaceModelsAfterSessionClear(current); + } else { + delete next.commands; + delete next.skills; + delete next.models; + delete next.currentModel; + delete next.currentMode; + delete next.contextWindow; + delete next.providers; + delete next.gitBranch; + delete next.gitStatus; + } } return { ...next, @@ -266,6 +297,8 @@ export function createDaemonSessionActions({ passiveAssistantDoneTimerRef, getCreateSessionRequest, createDetachedSession, + createDetachedStandaloneSession, + getDefaultSessionContext, getConnection, hasSessionActivePrompt, resetCurrentSessionActivePrompt, @@ -274,7 +307,7 @@ export function createDaemonSessionActions({ setConnection, setPromptStatus, setRestoreSessionId, - setRestoreWorkspaceCwd, + setRestoreSessionContext, setRestoreMode, setRestoreSessionNonce, setAttachSessionNonce, @@ -476,12 +509,13 @@ export function createDaemonSessionActions({ } store.reset(); setRestoreSessionId(undefined); - setRestoreWorkspaceCwd(undefined); + setRestoreSessionContext(undefined); } function startPendingSessionLoad( sessionId: string, mode: PendingSessionLoad['mode'], + sessionContext?: DaemonProductSessionContext, signal?: AbortSignal, replaySource?: PendingSessionLoad['replaySource'], ): Promise { @@ -513,7 +547,7 @@ export function createDaemonSessionActions({ if (sessionRef.current?.sessionId !== sessionId) { manualSessionClearRef.current = true; setRestoreSessionId(undefined); - setRestoreWorkspaceCwd(undefined); + setRestoreSessionContext(undefined); setConnection((current) => { if ( current.status !== 'connecting' || @@ -542,6 +576,7 @@ export function createDaemonSessionActions({ id: loadId, sessionId, mode, + ...(sessionContext ? { sessionContext } : {}), timeout, ...(mode !== 'attach' ? { requestTimeoutMs: restoreTimeouts.requestTimeoutMs } @@ -558,7 +593,10 @@ export function createDaemonSessionActions({ function startSessionSwitch( sessionId: string, mode: 'load' | 'resume', - workspaceCwd?: string, + options?: { + workspaceCwd?: string; + sessionContext?: DaemonProductSessionContext; + }, signal?: AbortSignal, replaySource?: PendingSessionLoad['replaySource'], ): Promise { @@ -571,15 +609,39 @@ export function createDaemonSessionActions({ ); } manualSessionClearRef.current = false; + const currentSession = sessionRef.current; + const currentSessionId = currentSession?.sessionId; + const currentConnection = getConnection(); + const currentSessionContext = + currentConnection.sessionContext ?? + (currentConnection.workspaceCwd + ? { kind: 'workspace' as const, cwd: currentConnection.workspaceCwd } + : currentSession?.workspaceCwd + ? { + kind: 'workspace' as const, + cwd: currentSession.workspaceCwd, + } + : undefined); + const fallbackContext = currentConnection.error + ? getDefaultSessionContext() + : (currentSessionContext ?? getDefaultSessionContext()); + const targetSessionContext = resolveActionSessionContext( + options?.sessionContext, + options?.workspaceCwd, + fallbackContext, + ); + const targetWorkspaceCwd = + targetSessionContext?.kind === 'workspace' + ? targetSessionContext.cwd + : undefined; const loadPromise = startPendingSessionLoad( sessionId, mode, + targetSessionContext, signal, replaySource, ); const pendingLoad = pendingSessionLoadRef.current; - const currentSession = sessionRef.current; - const currentSessionId = currentSession?.sessionId; const activePrompt = currentSessionId ? activePromptsRef.current.get(currentSessionId) : undefined; @@ -590,17 +652,24 @@ export function createDaemonSessionActions({ activePromptsRef.current.delete(currentSessionId); } resetCurrentSessionActivePrompt(); - const targetWorkspaceCwd = - workspaceCwd ?? - currentSession?.workspaceCwd ?? - // A failed switch leaves the target's workspace on the connection for - // error rendering; never let the next workspace-less load inherit it. - (getConnection().error ? undefined : getConnection().workspaceCwd); const reloadingCurrentSession = mode === 'load' && currentSessionId === sessionId && - normalizeWorkspaceIdentity(currentSession?.workspaceCwd) === - normalizeWorkspaceIdentity(targetWorkspaceCwd); + sessionContextKey(currentSessionContext) === + sessionContextKey(targetSessionContext); + const crossesNonWorkspaceBoundary = + sessionContextKey(currentSessionContext) !== + sessionContextKey(targetSessionContext) && + (currentSessionContext?.kind === 'standalone' || + currentSessionContext?.kind === 'live' || + targetSessionContext?.kind === 'standalone' || + targetSessionContext?.kind === 'live'); + const switchesNonWorkspaceSession = + currentConnection.sessionId !== undefined && + currentConnection.sessionId !== sessionId && + currentSessionContext?.kind === targetSessionContext?.kind && + (targetSessionContext?.kind === 'standalone' || + targetSessionContext?.kind === 'live'); if (currentSession) { const detachCurrentSession = () => currentSession.detach().catch((error: unknown) => { @@ -624,20 +693,28 @@ export function createDaemonSessionActions({ } if (!reloadingCurrentSession) sessionRef.current = undefined; if (!reloadingCurrentSession) { - setConnection((current) => ({ - ...current, - status: 'connecting', - sessionId, - workspaceCwd: targetWorkspaceCwd, - clientId: undefined, - displayName: undefined, - goalState: undefined, - error: undefined, - errorStatus: undefined, - missingSession: false, - loadingTranscript: true, - catchingUp: undefined, - })); + setConnection((current) => { + const base = + crossesNonWorkspaceBoundary || switchesNonWorkspaceSession + ? getConnectionAfterSessionClear(current, currentSessionId, false) + : current; + return { + ...base, + status: 'connecting', + sessionId, + sessionContext: targetSessionContext, + workspaceCwd: targetWorkspaceCwd, + standaloneSession: undefined, + clientId: undefined, + displayName: undefined, + goalState: undefined, + error: undefined, + errorStatus: undefined, + missingSession: false, + loadingTranscript: true, + catchingUp: undefined, + }; + }); } setPromptStatus('idle'); settledPromptsRef.current.clear(); @@ -645,7 +722,7 @@ export function createDaemonSessionActions({ if (!reloadingCurrentSession) store.reset(); setRestoreMode(mode); setRestoreSessionId(sessionId); - setRestoreWorkspaceCwd(targetWorkspaceCwd); + setRestoreSessionContext(targetSessionContext); setRestoreSessionNonce((nonce) => nonce + 1); return loadPromise.catch((error: unknown) => { // The failed target stays visible (sessionId + workspaceCwd) so the UI @@ -663,6 +740,13 @@ export function createDaemonSessionActions({ ...current, error: error instanceof Error ? error.message : String(error), errorStatus: extractHttpStatus(error), + standaloneSession: + targetSessionContext?.kind === 'standalone' + ? { + ...current.standaloneSession, + errorCode: getDaemonErrorCode(error), + } + : undefined, loadingTranscript: undefined, catchingUp: undefined, })); @@ -1256,7 +1340,12 @@ export function createDaemonSessionActions({ if (!session) return []; try { return await withActionTimeout( - session.client.listWorkspaceSessions(session.workspaceCwd, options), + getConnection().sessionContext?.kind === 'standalone' + ? session.client.listStandaloneSessions(options) + : session.client.listWorkspaceSessions( + session.workspaceCwd, + options, + ), 'List sessions timed out', ); } catch (error) { @@ -1270,7 +1359,7 @@ export function createDaemonSessionActions({ }, async loadSession(sessionId, options) { - return startSessionSwitch(sessionId, 'load', options?.workspaceCwd); + return startSessionSwitch(sessionId, 'load', options); }, async reloadSession(signal, options) { @@ -1283,26 +1372,34 @@ export function createDaemonSessionActions({ return startSessionSwitch( session.sessionId, 'load', - session.workspaceCwd, + { + sessionContext: getConnection().sessionContext ?? { + kind: 'workspace', + cwd: session.workspaceCwd, + }, + }, signal, options?.replaySource, ); }, async resumeSession(sessionId, options) { - return startSessionSwitch(sessionId, 'resume', options?.workspaceCwd); + return startSessionSwitch(sessionId, 'resume', options); }, async createSession(options?: { workspaceCwd?: string; + sessionContext?: DaemonProductSessionContext; approvalMode?: DaemonApprovalMode; sourceType?: string; worktree?: { slug?: string }; branch?: { name: string }; }) { + let targetSessionContext: DaemonProductSessionContext | undefined; let rawCreateStarted = false; let rawCreateSettled = false; let retireLateResult = false; + let publishStandaloneRecovery = false; const trackCreate = ( request: Promise, retire: (created: T) => Promise, @@ -1328,6 +1425,27 @@ export function createDaemonSessionActions({ }; try { manualSessionClearRef.current = false; + const currentConnection = getConnection(); + targetSessionContext = resolveActionSessionContext( + options?.sessionContext, + options?.workspaceCwd, + currentConnection.error + ? getDefaultSessionContext() + : (currentConnection.sessionContext ?? getDefaultSessionContext()), + ); + if (targetSessionContext?.kind === 'live') { + throw new Error('Live session context does not support create'); + } + if ( + targetSessionContext?.kind === 'standalone' && + (options?.sourceType !== undefined || + options?.worktree !== undefined || + options?.branch !== undefined) + ) { + throw new Error( + 'Standalone session creation does not support sourceType, worktree, or branch options', + ); + } // Fold the initial approval mode into the create request so the daemon // applies it atomically at spawn (`POST /session` → // `spawnOrAttach({ approvalMode })`), avoiding a follow-up @@ -1352,12 +1470,24 @@ export function createDaemonSessionActions({ ? session : undefined; if (activeSession) { + if (targetSessionContext?.kind === 'standalone') { + const nextClient = await trackCreate( + createDetachedStandaloneSession({ + ...(options?.approvalMode !== undefined + ? { approvalMode: options.approvalMode } + : {}), + }), + (created) => created.detach(), + ); + persistStableClientId(nextClient.clientId, nextClient.sessionId); + return nextClient.session; + } const nextSession = await withActionTimeout( trackCreate( activeSession.client.createOrAttachSession({ ...getCreateSessionRequest(), - ...(options?.workspaceCwd !== undefined - ? { workspaceCwd: options.workspaceCwd } + ...(targetSessionContext?.kind === 'workspace' + ? { workspaceCwd: targetSessionContext.cwd } : {}), ...requestOverrides, }), @@ -1373,13 +1503,29 @@ export function createDaemonSessionActions({ return nextSession; } - const nextSession = await withActionTimeout( - trackCreate( - createDetachedSession(options?.workspaceCwd, requestOverrides), - (created) => created.detach(), - ), - 'Create session timed out', - ); + publishStandaloneRecovery = targetSessionContext?.kind === 'standalone'; + const trackedCreate = trackCreate( + targetSessionContext?.kind === 'standalone' + ? createDetachedStandaloneSession({ + ...(options?.approvalMode !== undefined + ? { approvalMode: options.approvalMode } + : {}), + }) + : createDetachedSession( + targetSessionContext?.kind === 'workspace' + ? targetSessionContext.cwd + : undefined, + requestOverrides, + ), + (created) => created.detach(), + ); + const nextSession = + targetSessionContext?.kind === 'standalone' + ? await trackedCreate + : await withActionTimeout( + trackedCreate, + 'Create session timed out', + ); if (manualSessionClearRef.current) { try { await withActionTimeout( @@ -1397,24 +1543,77 @@ export function createDaemonSessionActions({ persistStableClientId(nextSession.clientId, nextSession.sessionId); sessionRef.current = nextSession; skipNextCleanupDetachSessionRef.current = nextSession; - setConnection((current) => ({ - ...current, - status: 'connected', - sessionId: nextSession.sessionId, - goalState: undefined, - ...(nextSession.clientId ? { clientId: nextSession.clientId } : {}), - workspaceCwd: nextSession.workspaceCwd, - error: undefined, - errorStatus: undefined, - missingSession: false, - })); + const createdSessionContext = + targetSessionContext?.kind === 'standalone' + ? targetSessionContext + : { + kind: 'workspace' as const, + cwd: nextSession.workspaceCwd, + }; + setConnection((current) => { + const base = + createdSessionContext.kind === 'workspace' + ? current + : getConnectionAfterSessionClear( + current, + current.sessionId, + false, + ); + return { + ...base, + status: 'connected', + sessionId: nextSession.sessionId, + sessionContext: createdSessionContext, + goalState: undefined, + ...(nextSession.clientId ? { clientId: nextSession.clientId } : {}), + workspaceCwd: + createdSessionContext.kind === 'workspace' + ? createdSessionContext.cwd + : undefined, + standaloneSession: + createdSessionContext.kind === 'standalone' + ? getStandaloneConnectionState(nextSession.session) + : undefined, + error: undefined, + errorStatus: undefined, + missingSession: false, + }; + }); return nextSession; } catch (error) { if (rawCreateStarted && !rawCreateSettled) retireLateResult = true; + if ( + publishStandaloneRecovery && + error instanceof DaemonStandaloneCreationOutcomeUnknownError + ) { + setConnection((current) => { + const base = getConnectionAfterSessionClear( + current, + current.sessionId, + false, + ); + return { + ...base, + status: 'error', + sessionId: error.sessionId, + sessionContext: { kind: 'standalone' }, + workspaceCwd: undefined, + standaloneSession: { + creationRecovery: error.recovery, + errorCode: getDaemonErrorCode(error.originalError), + }, + error: error.message, + errorStatus: extractHttpStatus(error.originalError), + missingSession: false, + }; + }); + } throw dispatchActionError( addNotice, `Create session failed${ - options?.workspaceCwd ? ` (workspace: ${options.workspaceCwd})` : '' + targetSessionContext?.kind === 'workspace' + ? ` (workspace: ${targetSessionContext.cwd})` + : '' }`, error, 'create_session', @@ -1429,7 +1628,13 @@ export function createDaemonSessionActions({ 'Attach session failed', 'attach_session', ); - const loadPromise = startPendingSessionLoad(session.sessionId, 'attach'); + const loadPromise = startPendingSessionLoad( + session.sessionId, + 'attach', + getConnection().sessionContext, + ); + const targetSessionContext = getConnection().sessionContext; + setRestoreSessionContext(targetSessionContext); setAttachSessionNonce((nonce) => nonce + 1); return loadPromise; }, @@ -1452,6 +1657,14 @@ export function createDaemonSessionActions({ }, async newSession() { + if (getConnection().sessionContext?.kind === 'live') { + throw dispatchActionError( + addNotice, + 'Create session failed', + new Error('Live session context does not support create'), + 'create_session', + ); + } manualSessionClearRef.current = false; clearActiveSessionState(); setConnection((current) => ({ diff --git a/packages/webui/src/daemon/session/index.ts b/packages/webui/src/daemon/session/index.ts index 3360bf3a5df..64c7da79f5d 100644 --- a/packages/webui/src/daemon/session/index.ts +++ b/packages/webui/src/daemon/session/index.ts @@ -29,6 +29,8 @@ export type { DaemonConnectionState, DaemonConnectionStatus, DaemonModelInfo, + DaemonProductSessionContext, + DaemonStandaloneConnectionState, DaemonNoticeCategory, DaemonNoticeOperation, DaemonNoticeSeverity, diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index 3ba3faa59d1..f5a72784a2d 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -944,6 +944,36 @@ describe('updateConnectionFromDaemonEvent', () => { expect(next).toBe(current); }); + it('ignores git branch changes for standalone sessions', () => { + const current: DaemonConnectionState = { + status: 'connected', + sessionContext: { kind: 'standalone' }, + }; + + const next = applyEvent(current, { + v: 1, + type: 'git_branch_changed', + data: { branch: 'internal-runtime-branch' }, + }); + + expect(next).toBe(current); + }); + + it('ignores git branch changes for Live sessions', () => { + const current: DaemonConnectionState = { + status: 'connected', + sessionContext: { kind: 'live' }, + }; + + const next = applyEvent(current, { + v: 1, + type: 'git_branch_changed', + data: { branch: 'internal-runtime-branch' }, + }); + + expect(next).toBe(current); + }); + it('stores the enriched git status pushed for the current workspace', () => { const next = applyEvent( { status: 'connected', workspaceCwd: '/workspace' }, @@ -987,6 +1017,40 @@ describe('updateConnectionFromDaemonEvent', () => { expect(next).toBe(current); }); + it('ignores git status pushes for standalone sessions', () => { + const current: DaemonConnectionState = { + status: 'connected', + sessionContext: { kind: 'standalone' }, + }; + + const next = applyEvent(current, { + v: 1, + type: 'git_status_changed', + data: { v: 2, branch: 'main', staged: 3 }, + }); + + expect(next).toBe(current); + }); + + it('ignores git status pushes for Live sessions', () => { + const current: DaemonConnectionState = { + status: 'connected', + sessionContext: { kind: 'live' }, + }; + + const next = applyEvent(current, { + v: 1, + type: 'git_status_changed', + data: { + v: 2, + branch: 'internal-runtime-branch', + staged: 1, + }, + }); + + expect(next).toBe(current); + }); + it('replaces commands and skills from an available_commands_update', () => { const next = applyEvent( { status: 'connected', workspaceCwd: '/workspace' }, diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 742f57e37d7..af27bc7e559 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -327,7 +327,9 @@ export function updateConnectionFromDaemonEvent( const workspaceCwd = getString(data, 'workspaceCwd'); const branch = getString(data, 'branch'); setConnection((current) => - workspaceCwd && workspaceCwd !== current.workspaceCwd + (current.sessionContext !== undefined && + current.sessionContext.kind !== 'workspace') || + (workspaceCwd && workspaceCwd !== current.workspaceCwd) ? current : { ...current, gitBranch: branch }, ); @@ -337,7 +339,9 @@ export function updateConnectionFromDaemonEvent( const data = getRecord(event.data); const workspaceCwd = getString(data, 'workspaceCwd'); setConnection((current) => - workspaceCwd && workspaceCwd !== current.workspaceCwd + (current.sessionContext !== undefined && + current.sessionContext.kind !== 'workspace') || + (workspaceCwd && workspaceCwd !== current.workspaceCwd) ? current : { ...current, diff --git a/packages/webui/src/daemon/session/session-context.test.ts b/packages/webui/src/daemon/session/session-context.test.ts new file mode 100644 index 00000000000..ce5723be5aa --- /dev/null +++ b/packages/webui/src/daemon/session/session-context.test.ts @@ -0,0 +1,424 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + DaemonSession, + DaemonWorkspaceCapability, +} from '@qwen-code/sdk/daemon'; +import { + getDaemonErrorCode, + getStandaloneConnectionState, + isDaemonErrorExplicitlyNonRetryable, + resolveActionSessionContext, + resolveLiveSessionWorkspaceCwd, + resolveProviderSessionContext, + restoreSessionContextMatches, + sessionContextKey, +} from './session-context.js'; + +describe('session context', () => { + it('normalizes legacy workspace inputs at the provider boundary', () => { + expect( + resolveProviderSessionContext(undefined, '/project', '/primary'), + ).toEqual({ kind: 'workspace', cwd: '/project' }); + expect( + resolveProviderSessionContext(undefined, undefined, '/primary'), + ).toEqual({ kind: 'workspace', cwd: '/primary' }); + }); + + it('accepts matching explicit workspace inputs after path normalization', () => { + expect( + resolveProviderSessionContext( + { kind: 'workspace', cwd: 'C:\\repo\\' }, + 'C:/repo', + '/primary', + ), + ).toEqual({ kind: 'workspace', cwd: 'C:\\repo\\' }); + }); + + it('rejects conflicting or workspace-qualified non-workspace contexts', () => { + expect(() => + resolveProviderSessionContext( + { kind: 'workspace' } as unknown as Parameters< + typeof resolveProviderSessionContext + >[0], + undefined, + undefined, + ), + ).toThrow('Workspace session context requires a cwd'); + expect(() => + resolveProviderSessionContext( + { kind: 'workspace', cwd: '/one' }, + '/two', + undefined, + ), + ).toThrow('sessionContext.cwd conflicts with workspaceCwd'); + expect(() => + resolveProviderSessionContext( + { kind: 'standalone' }, + '/primary', + undefined, + ), + ).toThrow('standalone session context cannot include workspaceCwd'); + expect(() => + resolveProviderSessionContext({ kind: 'live' }, '/primary', undefined), + ).toThrow('live session context cannot include workspaceCwd'); + }); + + it('does not treat an inherited workspace as a conflict for explicit non-workspace contexts', () => { + expect( + resolveProviderSessionContext( + { kind: 'standalone' }, + undefined, + '/primary', + ), + ).toEqual({ kind: 'standalone' }); + }); + + it('uses the active context only when an action supplies no override', () => { + expect( + resolveActionSessionContext(undefined, undefined, { + kind: 'standalone', + }), + ).toEqual({ kind: 'standalone' }); + expect( + resolveActionSessionContext(undefined, '/project', { + kind: 'standalone', + }), + ).toEqual({ kind: 'workspace', cwd: '/project' }); + }); + + it('rejects standalone-shaped connection state from another source', () => { + expect( + getStandaloneConnectionState({ + sourceType: 'default', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output', + workingDirectory: { state: 'ready' }, + } as unknown as DaemonSession), + ).toBeUndefined(); + }); + + it('resolves one uniquely trusted non-primary Live runtime', () => { + expect( + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'primary', + cwd: '/project', + primary: true, + trusted: true, + }, + { + id: 'live', + cwd: '/conversations', + primary: false, + trusted: true, + kind: 'live', + }, + ], + }), + ).toBe('/conversations'); + }); + + it('fails closed for missing, ambiguous, or untrusted Live runtimes', () => { + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: [], + modelServices: [], + workspaces: [ + { + id: 'live', + cwd: '/conversations', + primary: false, + trusted: true, + kind: 'live', + }, + ], + }), + ).toThrow('does not advertise multi-workspace session routing'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [], + }), + ).toThrow('does not advertise a Live session runtime'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live-1', + cwd: '/one', + primary: false, + trusted: true, + kind: 'live', + }, + { + id: 'live-2', + cwd: '/two', + primary: false, + trusted: true, + kind: 'live', + }, + ], + }), + ).toThrow('multiple Live session runtimes'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live', + cwd: '/conversations', + primary: false, + trusted: false, + kind: 'live', + }, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 7, + cwd: '/conversations', + primary: false, + trusted: true, + kind: 'live', + } as unknown as DaemonWorkspaceCapability, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live', + cwd: '/conversations', + trusted: true, + kind: 'live', + } as unknown as DaemonWorkspaceCapability, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live', + cwd: 'C:\\conversations', + primary: false, + trusted: true, + kind: 'live', + }, + { + id: 'other', + cwd: 'C:/conversations/', + primary: false, + trusted: true, + }, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live', + cwd: '/conversations', + primary: false, + trusted: true, + kind: 'live', + }, + { + id: 'live', + cwd: '/other', + primary: false, + trusted: true, + }, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: '', + cwd: '/conversations', + primary: false, + trusted: true, + kind: 'live', + }, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live', + cwd: '', + primary: false, + trusted: true, + kind: 'live', + }, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live', + primary: false, + trusted: true, + kind: 'live', + } as unknown as DaemonWorkspaceCapability, + ], + }), + ).toThrow('not uniquely trusted'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + modelServices: [], + workspaces: [], + } as unknown as Parameters[0]), + ).toThrow('does not advertise multi-workspace session routing'); + expect(() => + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: { live: '/conversations' }, + } as unknown as Parameters[0]), + ).toThrow('does not advertise a Live session runtime'); + expect( + resolveLiveSessionWorkspaceCwd({ + v: 1, + mode: 'native', + features: ['multi_workspace_sessions'], + modelServices: [], + workspaces: [ + { + id: 'live', + cwd: '/conversations', + primary: false, + trusted: true, + kind: 'live', + }, + { + id: 'other', + cwd: 7, + primary: false, + trusted: true, + } as unknown as DaemonWorkspaceCapability, + ], + }), + ).toBe('/conversations'); + }); + + it('builds stable keys and reads structured daemon error codes', () => { + expect(sessionContextKey({ kind: 'workspace', cwd: 'C:\\repo\\' })).toBe( + 'workspace:C:/repo', + ); + expect(sessionContextKey({ kind: 'standalone' })).toBe('standalone'); + expect( + restoreSessionContextMatches(undefined, { + kind: 'workspace', + cwd: '/primary', + }), + ).toBe(true); + expect(restoreSessionContextMatches(undefined, { kind: 'live' })).toBe( + false, + ); + expect( + restoreSessionContextMatches( + { kind: 'workspace', cwd: '/same' }, + { kind: 'workspace', cwd: '/same' }, + ), + ).toBe(true); + expect( + restoreSessionContextMatches( + { kind: 'workspace', cwd: '/one' }, + { kind: 'workspace', cwd: '/two' }, + ), + ).toBe(false); + expect( + restoreSessionContextMatches( + { kind: 'standalone' }, + { kind: 'standalone' }, + ), + ).toBe(true); + expect( + restoreSessionContextMatches({ kind: 'live' }, { kind: 'live' }), + ).toBe(true); + expect( + restoreSessionContextMatches({ kind: 'standalone' }, undefined), + ).toBe(false); + expect( + getDaemonErrorCode({ body: { code: 'working_directory_missing' } }), + ).toBe('working_directory_missing'); + expect(getDaemonErrorCode(new Error('no body'))).toBeUndefined(); + expect( + isDaemonErrorExplicitlyNonRetryable({ + body: { code: 'working_directory_compromised', retryable: false }, + }), + ).toBe(true); + expect( + isDaemonErrorExplicitlyNonRetryable({ + body: { code: 'working_directory_missing', retryable: true }, + }), + ).toBe(false); + }); +}); diff --git a/packages/webui/src/daemon/session/session-context.ts b/packages/webui/src/daemon/session/session-context.ts new file mode 100644 index 00000000000..048fe44d5b8 --- /dev/null +++ b/packages/webui/src/daemon/session/session-context.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonCapabilities, + DaemonSession, + DaemonStandaloneSession, +} from '@qwen-code/sdk/daemon'; +import type { + DaemonProductSessionContext, + DaemonStandaloneConnectionState, +} from './types.js'; + +function normalizeWorkspaceCwd(value: string): string { + return value.replace(/\\/g, '/').replace(/\/+$/, '') || '/'; +} + +export function resolveProviderSessionContext( + sessionContext: DaemonProductSessionContext | undefined, + workspaceCwd: string | undefined, + inheritedWorkspaceCwd: string | undefined, +): DaemonProductSessionContext | undefined { + if (!sessionContext) { + const cwd = workspaceCwd ?? inheritedWorkspaceCwd; + return cwd ? { kind: 'workspace', cwd } : undefined; + } + if (sessionContext.kind === 'workspace') { + if (!sessionContext.cwd) { + throw new Error('Workspace session context requires a cwd'); + } + if ( + workspaceCwd !== undefined && + normalizeWorkspaceCwd(workspaceCwd) !== + normalizeWorkspaceCwd(sessionContext.cwd) + ) { + throw new Error('sessionContext.cwd conflicts with workspaceCwd'); + } + return sessionContext; + } + if (workspaceCwd !== undefined) { + throw new Error( + `${sessionContext.kind} session context cannot include workspaceCwd`, + ); + } + return sessionContext; +} + +export function resolveActionSessionContext( + sessionContext: DaemonProductSessionContext | undefined, + workspaceCwd: string | undefined, + fallback: DaemonProductSessionContext | undefined, +): DaemonProductSessionContext | undefined { + return ( + resolveProviderSessionContext( + sessionContext, + workspaceCwd, + sessionContext || workspaceCwd + ? undefined + : fallback?.kind === 'workspace' + ? fallback.cwd + : undefined, + ) ?? fallback + ); +} + +export function resolveLiveSessionWorkspaceCwd( + capabilities: DaemonCapabilities, +): string { + if ( + !Array.isArray(capabilities.features) || + !capabilities.features.includes('multi_workspace_sessions') + ) { + throw new Error( + 'Daemon does not advertise multi-workspace session routing', + ); + } + const workspaces = Array.isArray(capabilities.workspaces) + ? capabilities.workspaces + : []; + const liveWorkspaces = workspaces.filter( + (workspace) => workspace.kind === 'live', + ); + if (liveWorkspaces.length !== 1) { + throw new Error( + liveWorkspaces.length === 0 + ? 'Daemon does not advertise a Live session runtime' + : 'Daemon advertises multiple Live session runtimes', + ); + } + const workspace = liveWorkspaces[0]!; + if ( + workspace.trusted !== true || + workspace.primary !== false || + typeof workspace.id !== 'string' || + workspace.id.length === 0 || + typeof workspace.cwd !== 'string' || + workspace.cwd.length === 0 + ) { + throw new Error('Daemon Live session runtime is not uniquely trusted'); + } + const normalizedWorkspaceCwd = normalizeWorkspaceCwd(workspace.cwd); + if ( + workspaces.filter((entry) => entry.id === workspace.id).length !== 1 || + workspaces.filter( + (entry) => + typeof entry.cwd === 'string' && + entry.cwd.length > 0 && + normalizeWorkspaceCwd(entry.cwd) === normalizedWorkspaceCwd, + ).length !== 1 + ) { + throw new Error('Daemon Live session runtime is not uniquely trusted'); + } + return workspace.cwd; +} + +export function sessionContextKey( + context: DaemonProductSessionContext | undefined, +): string { + return context?.kind === 'workspace' + ? `workspace:${normalizeWorkspaceCwd(context.cwd)}` + : (context?.kind ?? 'legacy'); +} + +export function restoreSessionContextMatches( + requested: DaemonProductSessionContext | undefined, + active: DaemonProductSessionContext | undefined, +): boolean { + if (requested === undefined) { + return active === undefined || active.kind === 'workspace'; + } + return sessionContextKey(requested) === sessionContextKey(active); +} + +function getDaemonErrorBody( + error: unknown, +): Record | undefined { + if ( + typeof error !== 'object' || + error === null || + !('body' in error) || + typeof error.body !== 'object' || + error.body === null || + Array.isArray(error.body) + ) { + return undefined; + } + return error.body as Record; +} + +export function getDaemonErrorCode(error: unknown): string | undefined { + const code = getDaemonErrorBody(error)?.['code']; + return typeof code === 'string' ? code : undefined; +} + +export function isDaemonErrorExplicitlyNonRetryable(error: unknown): boolean { + return getDaemonErrorBody(error)?.['retryable'] === false; +} + +export function getStandaloneConnectionState( + session: DaemonSession | undefined, +): DaemonStandaloneConnectionState | undefined { + if (!session) return undefined; + const standalone = session as Partial; + if ( + standalone.sourceType !== 'standalone' || + standalone.context?.kind !== 'standalone' || + standalone.projectlessOutputDirectory === undefined || + standalone.workingDirectory === undefined + ) { + return undefined; + } + return { + projectlessOutputDirectory: standalone.projectlessOutputDirectory, + workingDirectory: standalone.workingDirectory, + }; +} diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 5dbe4f73f90..009e29c54f9 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -40,6 +40,8 @@ import type { DaemonTranscriptStore, DaemonWorkspaceGitStatus, DaemonWorkspaceProvidersStatus, + DaemonStandaloneCreationRecovery, + DaemonStandaloneWorkingDirectory, HeartbeatResult, GoalControlRequest, GoalSnapshotV2, @@ -66,6 +68,18 @@ export interface DaemonSessionOwnerGuard { capture(): DaemonSessionOwnerSnapshot; } +export type DaemonProductSessionContext = + | { kind: 'workspace'; cwd: string } + | { kind: 'standalone' } + | { kind: 'live' }; + +export interface DaemonStandaloneConnectionState { + projectlessOutputDirectory?: string; + workingDirectory?: DaemonStandaloneWorkingDirectory; + creationRecovery?: DaemonStandaloneCreationRecovery; + errorCode?: string; +} + export interface DaemonConnectionState { status: DaemonConnectionStatus; sessionId?: string; @@ -76,7 +90,12 @@ export interface DaemonConnectionState { * the session and do not use this id as an ownership boundary. */ clientId?: string; + /** Explicit product and restore context for the current or pending session. */ + sessionContext?: DaemonProductSessionContext; + /** Product workspace cwd. Always absent for standalone and Live contexts. */ workspaceCwd?: string; + /** Standalone-only working-directory and outcome recovery state. */ + standaloneSession?: DaemonStandaloneConnectionState; /** Current Git branch, short detached-HEAD hash, or undefined outside Git. */ gitBranch?: string; /** @@ -135,8 +154,10 @@ export interface DaemonSessionProviderProps { baseUrl?: string; /** Bearer token. Optional when nested inside DaemonWorkspaceProvider (inherited). */ token?: string; - /** Workspace cwd used when creating, loading, or resuming daemon sessions. */ + /** Legacy workspace context. Do not combine with standalone or Live context. */ workspaceCwd?: string; + /** Explicit product context. Legacy callers may continue using workspaceCwd. */ + sessionContext?: DaemonProductSessionContext; /** Session id to load. Undefined keeps the page empty until a prompt creates one. */ sessionId?: string; /** Stable client identity to reuse for session-scoped daemon requests. */ @@ -412,7 +433,10 @@ export interface DaemonSessionActions { }): Promise; loadSession( sessionId: string, - options?: { workspaceCwd?: string }, + options?: { + workspaceCwd?: string; + sessionContext?: DaemonProductSessionContext; + }, ): Promise; /** `memory` replay is reserved for the provider's live-journal repair. */ reloadSession( @@ -421,15 +445,20 @@ export interface DaemonSessionActions { ): Promise; resumeSession( sessionId: string, - options?: { workspaceCwd?: string }, + options?: { + workspaceCwd?: string; + sessionContext?: DaemonProductSessionContext; + }, ): Promise; /** * Create a daemon session and update local session state. Callers that need * transcript/event streaming must follow with `attachSession()`. * - * `options.workspaceCwd` targets a specific registered workspace runtime for - * this call only (multi-workspace daemons). Omit it to keep the provider's - * active workspace / primary fallback. + * `options.sessionContext` selects the exact product context. The legacy + * `options.workspaceCwd` form targets a registered workspace runtime only. + * Omit both to keep the provider's active context / legacy primary fallback. + * Standalone creation is not automatically retried; Live creation is not + * supported. * * `options.approvalMode` seeds the session's approval mode in the create * request itself, so the daemon applies it atomically at spawn instead of @@ -439,6 +468,7 @@ export interface DaemonSessionActions { */ createSession(options?: { workspaceCwd?: string; + sessionContext?: DaemonProductSessionContext; approvalMode?: DaemonApprovalMode; sourceType?: string; worktree?: { slug?: string }; @@ -597,6 +627,7 @@ export interface PendingSessionLoad { id: number; sessionId: string; mode: 'load' | 'resume' | 'attach'; + sessionContext?: DaemonProductSessionContext; timeout?: ReturnType; /** SDK timeout for load/resume; `0` disables its timer. */ requestTimeoutMs?: number;