fix(web-shell): fall back to a plain draft for New task in a Live chat without a draft target (#12626)

* fix(web-shell): fall back to a plain draft for New task in a Live chat without a draft target

In a Live chat session, the sidebar New task passes leaveLive to leave the
Live path, but the escape only covered a trusted primary workspace or the
standalone_sessions_v1 capability. When neither applies the code kept the
Live path and called startLive('new'), which throws LiveUnavailableError
on hosts without Live Voice — the click surfaced only a raw error toast
and the action was silently discarded.

Add the terminal fallback the leaveLive branch was missing: drop the live
context and open a plain cwd-less draft, the same place a cold New task
lands in that setup (as #12516's risk note already described). The
existing test that pinned the fall-through is converted into a red-to-
green regression test asserting startLive is not called and the next
prompt creates a session without a workspace context.

Fixes #12620

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmufb38v80f

* fix(web-shell): drop the connection's live context when New task leaves Live

Setting the pending session context to undefined does not leave Live:
undefined is the downstream sentinel for "inherit from the connection", and
getConnectionAfterSessionClear deliberately keeps sessionContext. The first
prompt therefore resolved back to { kind: 'live' } and createSession threw
'Live session context does not support create' (#12620).

Make leaving a real state change: clearSession accepts { dropSessionContext }
and forwards it to getConnectionAfterSessionClear, and App sets it only when
this click chose no context while the connection is live. A workspace
connection still inherits its cwd, and the other clearSession callers pass no
options, so their behaviour is unchanged.

The converted test's clearSession mock now mirrors production (always clear
sessionId, drop sessionContext only when asked), so it fails without the fix.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmufdy4iv1n

* test(web-shell): pin the session-context drop with regression tests

Closes the two coverage gaps the sandboxed verify report named on the
previous head: both the reducer's opt-in dropSessionContext parameter and
the `kind === 'live'` half of the drop predicate survived mutation
testing with every committed test green.

- actions.test.ts drives getConnectionAfterSessionClear with
  dropSessionContext=true (sessionContext gone) and with the default
  3-arg shape (context kept, input state not mutated).
- App.test.tsx covers the { kind: 'global' } new-session draft with no
  trusted primary and no standalone capability: a workspace connection
  reaches the same `nextContext === undefined` there, and its context
  must NOT be dropped, so the live clause is load-bearing.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmufv3et42h

* test(web-shell): drive clearSession's dropSessionContext through the real action

Review R1-1: the option was the one link of the #12620 fix no committed
test drove - the App tests re-implement the drop in hand-written mocks, so
flipping the forwarding to a constant false left the whole suite green.

- Widen DaemonSessionActions.clearSession to declare the options the
  implementation already accepts (the App's local shadow type had to work
  around the canonical contract).
- Add wiring cases on the real createDaemonSessionActions: a live
  connection loses its sessionContext with { dropSessionContext: true }
  and keeps it on an arg-less clear.

Mutation check: with the forwarding forced to false, the drop case goes
red on the sessionContext assertion while the other 300 tests stay green.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmug2ybzm0q

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-25 04:56:28 +00:00
1 parent 78faaec8a1
commit 3541e1f5b0
5 files changed
+194 -15

No files matched your search

+84 -5
View File
@@ -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 () => {
+28 -7
View File
@@ -1542,7 +1542,7 @@ type SessionActionsWithCreate = {
branch?: { name: string; baseBranch: string };
}>;
attachSession: () => Promise<void>;
clearSession: () => Promise<void>;
clearSession: (options?: { dropSessionContext?: boolean }) => Promise<void>;
releaseSession: (sessionId: string) => Promise<void>;
};
@@ -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 (
@@ -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');
@@ -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);
@@ -590,7 +590,7 @@ export interface DaemonSessionActions {
branch?: { name: string };
}): Promise<DaemonSession>;
attachSession(): Promise<void>;
clearSession(): Promise<void>;
clearSession(options?: { dropSessionContext?: boolean }): Promise<void>;
newSession(): Promise<void>;
releaseSession(sessionId: string): Promise<void>;
closeSession(): Promise<void>;