diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 98b1c1b28e8..606e503b46a 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -22467,6 +22467,53 @@ describe('App session callbacks', () => { ); }); + it('keeps a workspace context when a global new-session draft has no cwd', async () => { + // The missing-session "New session" button asks for a { kind: 'global' } + // draft. With no standalone capability and no trusted primary, that draft + // has no cwd either, so it reaches the very same `nextContext === undefined` + // the leave-Live fallback relies on — but the connection here is a + // workspace one, and only a context this click chose to leave may be + // dropped (#12620). + mockConnection.status = 'disconnected'; + mockConnection.sessionId = undefined; + mockConnection.error = 'Session load failed'; + mockConnection.errorStatus = 404; + mockConnection.missingSession = true; + mockConnection.sessionContext = { kind: 'workspace', cwd: '/workspace' }; + mockWorkspace.capabilities = { + features: [], + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: false }, + ], + } as typeof mockWorkspace.capabilities; + // Mirrors production `clearSession`: the session id always goes, but the + // connection's session context only goes when the caller asks to drop it. + mockSessionActions.clearSession.mockImplementation( + async (options?: { dropSessionContext?: boolean }) => { + mockConnection.sessionId = undefined; + if (options?.dropSessionContext) { + mockConnection.sessionContext = undefined; + } + }, + ); + const { container } = renderApp(); + await flush(); + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'New session') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + expect(mockSessionActions.clearSession).toHaveBeenCalledWith(undefined); + expect(mockConnection.sessionContext).toEqual({ + kind: 'workspace', + cwd: '/workspace', + }); + }); + it('shows an automatic recap when the session remains active', async () => { const { recap } = await triggerAutoRecap(); await act(async () => { @@ -38166,7 +38213,7 @@ describe('App session callbacks', () => { ); }); - it('keeps the Live path for New task when no draft target exists', async () => { + it('opens a plain draft from New task in a Live chat when no draft target exists', async () => { mockConnection.sessionContext = { kind: 'live' }; mockConnection.workspaceCwd = ''; mockWorkspace.capabilities = { @@ -38181,7 +38228,19 @@ describe('App session callbacks', () => { }, ], } as typeof mockWorkspace.capabilities; - const { container } = renderApp(); + // Mirrors production `clearSession`: the session id always goes, but the + // connection's session context only goes when the caller asks to drop it + // — which is exactly what the leave-Live fallback has to do, because an + // undefined pending context means "inherit from the connection". + mockSessionActions.clearSession.mockImplementation( + async (options?: { dropSessionContext?: boolean }) => { + mockConnection.sessionId = undefined; + if (options?.dropSessionContext) { + mockConnection.sessionContext = undefined; + } + }, + ); + const { container, rerender } = renderApp(); await flush(); await act(async () => { @@ -38190,10 +38249,30 @@ describe('App session callbacks', () => { ?.click(); await Promise.resolve(); }); - await flush(); + await act(async () => { + rerender(); + await flush(); + }); - expect(mockWorkspace.client.startLive).toHaveBeenCalledWith('new'); - expect(mockSessionActions.clearSession).not.toHaveBeenCalled(); + expect(mockWorkspace.client.startLive).not.toHaveBeenCalled(); + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + expect(mockSessionActions.clearSession).toHaveBeenCalledWith({ + dropSessionContext: true, + }); + expect(mockConnection.sessionContext).toBeUndefined(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('task prompt'); + await vi.waitFor(() => { + expect(mockSessionActions.createSession).toHaveBeenCalled(); + }); + }); + expect(mockSessionActions.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceCwd: undefined, + sessionContext: undefined, + }), + ); }); it('keeps a legacy Live runtime cwd out of workspace product context', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a9e8783d4cb..c647bff5789 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1542,7 +1542,7 @@ type SessionActionsWithCreate = { branch?: { name: string; baseBranch: string }; }>; attachSession: () => Promise; - clearSession: () => Promise; + clearSession: (options?: { dropSessionContext?: boolean }) => Promise; releaseSession: (sessionId: string) => Promise; }; @@ -1552,7 +1552,10 @@ type NewSessionIntent = * Stay in the current context. From a Live chat that means a fresh Live * conversation, unless `leaveLive` sends the new chat to the trusted * primary workspace the way a cold draft starts (or to a standalone draft - * when there is no trusted primary). + * when there is no trusted primary, or to a plain cwd-less draft when the + * daemon offers neither). Leaving also drops the connection's live session + * context, so the resulting draft does not inherit Live on its first + * prompt. */ | { kind: 'inherit'; leaveLive?: boolean } | { kind: 'workspace'; cwd: string }; @@ -13430,10 +13433,15 @@ export function App({ } : undefined); if (nextContext?.kind === 'live' && intent.leaveLive) { - // Clearing keeps the connection's live context, so an undefined - // pending context would send the first prompt back to Live. Without - // a trusted primary, leave for a standalone draft when the daemon - // offers one and otherwise keep the Live path. + // Without a trusted primary, leave for a standalone draft when the + // daemon offers one; otherwise fall back to a plain cwd-less draft, + // the same as a cold draft in that setup, instead of keeping the + // Live path — attempting startLive('new') here throws when Live + // Voice is unavailable and the New task click is discarded + // (#12620). The undefined case only means "plain draft" because the + // clear below is told to drop the connection's live context: an + // undefined pending context means "inherit from the connection", so + // leaving it in place would send the first prompt back to Live. const primaryCwd = workspacesRef.current.find( (entry) => entry.primary && entry.trusted !== false, )?.cwd; @@ -13445,6 +13453,8 @@ export function App({ ) ) { nextContext = { kind: 'standalone' }; + } else { + nextContext = undefined; } } if (nextContext?.kind === 'live') { @@ -13535,12 +13545,23 @@ export function App({ closePanel(); } if (!opts?.keepView) showChat(); + // Leaving Live has to reach the connection: `undefined` is the + // downstream sentinel for "inherit from the connection", so clearing + // alone would hand the first prompt the live context we just left, and + // createSession rejects a live context (#12620). Only a context this + // click chose to leave is dropped — a workspace connection still + // inherits its cwd on the next prompt. + const dropSessionContextOnClear = + nextContext === undefined && + connectionRef.current.sessionContext?.kind === 'live'; let focusRequest: number | undefined; try { autoRecapVersionRef.current += 1; const clearPromise = ( sessionActions as typeof sessionActions & SessionActionsWithCreate - ).clearSession(); + ).clearSession( + dropSessionContextOnClear ? { dropSessionContext: true } : undefined, + ); focusRequest = scheduleComposerFocus(); await clearPromise; if ( diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index f060fd3b992..82df1eae20c 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -485,6 +485,37 @@ describe('getConnectionAfterSessionClear', () => { expect(next).not.toHaveProperty('standaloneSession'); expect(getWorkspaceModelsAfterSessionClear(current)).toBeUndefined(); }); + + it('drops the connection session context only when the clear asks for it', () => { + // Leaving Live has to be a real state change: `undefined` is the downstream + // sentinel for "inherit from the connection", so a cleared connection that + // still advertises { kind: 'live' } sends the next prompt straight back + // into Live, where createSession rejects a live context (#12620). + const current: DaemonConnectionState = { + status: 'connected', + sessionId: 'live-a', + sessionContext: { kind: 'live' }, + commands: [commandInfo('old-command')], + skills: ['old-skill'], + }; + + const dropped = getConnectionAfterSessionClear( + current, + 'live-a', + undefined, + true, + ); + + expect(dropped).not.toHaveProperty('sessionContext'); + expect(dropped).not.toHaveProperty('sessionId'); + + // The drop stays opt-in: every other call site keeps the 3-arg shape, so a + // context nobody chose to leave must survive the clear, and the incoming + // state is never mutated in place. + const kept = getConnectionAfterSessionClear(current, 'live-a'); + expect(kept.sessionContext).toEqual({ kind: 'live' }); + expect(current.sessionContext).toEqual({ kind: 'live' }); + }); }); /** @@ -5780,6 +5811,40 @@ describe('createDaemonSessionActions', () => { }, ); + it('drops the connection session context through the real clearSession action', async () => { + const session = createMockSession('session-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'session-a', + sessionContext: { kind: 'live' }, + }, + session, + }); + + await actions.clearSession({ dropSessionContext: true }); + + expect(getConnection().sessionContext).toBeUndefined(); + expect(getConnection().sessionId).toBeUndefined(); + }); + + it('keeps the connection session context when clearSession drops only the session', async () => { + const session = createMockSession('session-a'); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'session-a', + sessionContext: { kind: 'live' }, + }, + session, + }); + + await actions.clearSession(); + + expect(getConnection().sessionId).toBeUndefined(); + expect(getConnection().sessionContext).toEqual({ kind: 'live' }); + }); + it('captures and marks a clear before waiting for persisted reasoning', async () => { const session = createMockSession('session-a'); const replacement = createMockSession('session-b'); diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index 64b7bd186ee..a3a95b860de 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -338,6 +338,7 @@ export function getConnectionAfterSessionClear( clearedSessionId: string | undefined, preserveWorkspaceMetadata = current.sessionContext === undefined || current.sessionContext.kind === 'workspace', + dropSessionContext = false, ): DaemonConnectionState { const next = { ...current }; if (!clearedSessionId || current.sessionId === clearedSessionId) { @@ -361,6 +362,14 @@ export function getConnectionAfterSessionClear( delete next.supportedCommands; delete next.context; delete next.reasoning; + if (dropSessionContext) { + // Leaving a context (Live, in practice) has to be a real state change. + // An undefined pending session context means "inherit from the + // connection" downstream, so a cleared connection that still advertises + // the old context sends the next prompt straight back into it — + // `createSession` then rejects a live context (#12620). + delete next.sessionContext; + } if (preserveWorkspaceMetadata) { // Keep `commands`/`skills`: they are workspace-scoped (skills, custom, // MCP-prompt and workflow slash commands all live at the workspace/config @@ -2364,7 +2373,7 @@ export function createDaemonSessionActions({ return loadPromise; }, - async clearSession() { + async clearSession(options?: { dropSessionContext?: boolean }) { const session = sessionRef.current; manualSessionClearRef.current = true; if (pendingPersistedReasoningAction) { @@ -2376,7 +2385,12 @@ export function createDaemonSessionActions({ clearActiveSessionState(); sessionRef.current = undefined; setConnection((current) => - getConnectionAfterSessionClear(current, session?.sessionId), + getConnectionAfterSessionClear( + current, + session?.sessionId, + undefined, + options?.dropSessionContext === true, + ), ); if (refreshStandaloneOptions) { setRestoreSessionNonce((nonce) => nonce + 1); diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 99ac6be977a..804f8dbb12c 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -590,7 +590,7 @@ export interface DaemonSessionActions { branch?: { name: string }; }): Promise; attachSession(): Promise; - clearSession(): Promise; + clearSession(options?: { dropSessionContext?: boolean }): Promise; newSession(): Promise; releaseSession(sessionId: string): Promise; closeSession(): Promise;