From 976ddeab031ee1fb3b9205723ffd571ea2c1d01a Mon Sep 17 00:00:00 2001 From: Neil Date: Sun, 25 Oct 2026 10:03:14 -0700 Subject: [PATCH] fix(native-chat): resume a Claude chat with its saved permission mode A chat saved as Approve for me (auto) relaunched with --permission-mode default whenever the saved model catalog had no row confirming auto support for the chat's model (catalog not yet probed after an update/restart, a fingerprint miss, or a full model id). Claude Code already falls back from auto by itself on a model that can't run it, so launch the saved choice as is. Fixes #30032 Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- ...ed-agent-session-launch-permission.test.ts | 108 +++--------------- ...uctured-agent-session-launch-permission.ts | 35 +----- ...tured-agent-session-outgrown-child.test.ts | 84 -------------- src/shared/agent-chat-permission-mode.test.ts | 28 +---- src/shared/agent-chat-permission-mode.ts | 19 --- 5 files changed, 23 insertions(+), 251 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 45b083939926..93044652b46c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -1,110 +1,36 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' -function record(provider: string, saved: Record | undefined): AgentSessionRecord { +function launchPermission(provider: string, saved: Record | undefined) { const { permissionMode, ...options } = saved ?? {} - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent, options and mode. - return { sessionId: 'session-1', provider, options, permissionMode } as AgentSessionRecord -} - -const listing = ( - models: { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[] -): AgentSessionModelCatalogResult => ({ - origin: 'probe', - fetchedAt: 1, - models: models.map((model) => ({ - label: model.id, - efforts: [], - isDefault: false, - ...model - })) -}) - -function launchPermission( - provider: string, - options: Record | undefined, - answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' } -) { - const read = vi.fn(async () => { - if (answer instanceof Error) { - throw answer - } - return answer - }) - const modelCatalog = { - peek: () => null, - read, - retainedLiveModelIds: vi.fn(async () => []), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => undefined), - stop: vi.fn(), - providerStarted: vi.fn() - } - return { - read, - result: structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog }, - record(provider, options) - ) - } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's agent and mode. + const record = { sessionId: 'session-1', provider, options, permissionMode } as AgentSessionRecord + return structuredAgentSessionLaunchPermission({ agents: claudeAndCodexDeclared() }, record) } describe('the permission mode a chat starts with', () => { - it('launches a saved mode that needs no model as it is, without reading the catalog', async () => { - const claude = launchPermission('claude', { permissionMode: 'accept-edits' }) - await expect(claude.result).resolves.toBe('accept-edits') - expect(claude.read).not.toHaveBeenCalled() - const codex = launchPermission('codex', { permissionMode: 'auto' }) - await expect(codex.result).resolves.toBe('auto') - expect(codex.read).not.toHaveBeenCalled() - }) - - it('asks for a chat saved before modes existed', async () => { - await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') - }) - - it('runs Claude auto on a model the saved catalog lists as supporting it', async () => { - const supported = launchPermission( - 'claude', - { permissionMode: 'auto', model: 'opus' }, - listing([{ id: 'opus', supportsAutoMode: true }]) + it('launches the saved mode as it is', async () => { + await expect(launchPermission('claude', { permissionMode: 'accept-edits' })).resolves.toBe( + 'accept-edits' ) - await expect(supported.result).resolves.toBe('auto') - expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true })) + await expect(launchPermission('codex', { permissionMode: 'auto' })).resolves.toBe('auto') }) - it('matches a chat with no model to the listed default', async () => { + // #30032: a resumed chat saved as auto must not relaunch as Ask when no model list is at hand. + it('launches Claude auto without consulting a model list', async () => { await expect( - launchPermission( - 'claude', - { permissionMode: 'auto' }, - listing([ - { id: 'sonnet', isDefault: true, supportsAutoMode: false }, - { id: 'opus', supportsAutoMode: true } - ]) - ).result - ).resolves.toBe('ask') + launchPermission('claude', { permissionMode: 'auto', model: 'opus' }) + ).resolves.toBe('auto') }) - it('asks when the catalog cannot confirm the model runs auto', async () => { - for (const answer of [ - { origin: 'unknown' } as const, - listing([{ id: 'opus' }]), - listing([{ id: 'sonnet', supportsAutoMode: true }]), - new Error('catalog unavailable') - ]) { - await expect( - launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result - ).resolves.toBe('ask') - } + it('asks for a chat saved before modes existed, or with a mode its agent lacks', async () => { + await expect(launchPermission('claude', undefined)).resolves.toBe('ask') + await expect(launchPermission('codex', { permissionMode: 'accept-edits' })).resolves.toBe('ask') }) it('has none for an agent with no modes', async () => { - await expect(launchPermission('pi', { permissionMode: 'full-access' }).result).resolves.toBe( - null - ) + await expect(launchPermission('pi', { permissionMode: 'full-access' })).resolves.toBe(null) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index 83edf094be19..277e3925f430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -1,46 +1,19 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentChatPermissionModes, - chatModelSupportsAutoMode, - chatPermissionModeNeedsModelSupport, - effectiveChatPermissionMode, + savedChatPermissionMode, type AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -/** Use only the saved, folder-scoped catalog so startup never waits on a listing. */ -async function listedAutoModeSupport( - catalog: StructuredAgentSessionHostDeps['modelCatalog'], - record: AgentSessionRecord -): Promise { - const listing = await catalog - ?.read({ - agent: record.provider, - sessionId: record.sessionId, - savedOnly: true, - inSessionWorkspace: true - }) - .catch(() => null) - return ( - listing !== null && - listing !== undefined && - listing.origin !== 'unknown' && - chatModelSupportsAutoMode(listing.models, record.options?.model) - ) -} - -/** null for unregistered agents or agents without permission modes. */ +/** The saved choice, null without modes; Claude itself drops auto on models that can't run it. */ export async function structuredAgentSessionLaunchPermission( - deps: Pick, + deps: Pick, record: AgentSessionRecord ): Promise { const support = agentChatPermissionModes(record.provider) if (!support || !deps.agents.definition(record.provider)) { return null } - const intent = record.permissionMode - const autoSupported = - chatPermissionModeNeedsModelSupport(support, intent) && - (await listedAutoModeSupport(deps.modelCatalog, record)) - return effectiveChatPermissionMode(support, intent, autoSupported) + return savedChatPermissionMode(support, record.permissionMode) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 43d5df12502f..cffbd443dd4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -14,10 +14,7 @@ import { retireOutgrownStructuredAgentSessionChild, structuredAgentSessionChildReportedSignedOut } from './structured-agent-session-outgrown-child' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { AgentJournalItemBody, @@ -278,87 +275,6 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { ).not.toHaveBeenCalled() }) - describe('against the mode a start launches with', () => { - // Model Claude’s saved catalog: resolved default alias, auto on every model except haiku. - const listed = [ - { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, - { id: 'sonnet', isDefault: false, supportsAutoMode: true }, - { id: 'haiku', isDefault: false } - ] - const catalog = (rows: typeof listed) => ({ - peek: () => null, - read: vi.fn(async () => ({ - origin: 'probe' as const, - fetchedAt: 1, - models: rows.map((row) => ({ ...row, label: row.id, efforts: [] })) - })), - retainedLiveModelIds: vi.fn(async () => []), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => undefined), - stop: vi.fn(), - providerStarted: vi.fn() - }) - const launch = ({ permissionMode, ...options }: Record, rows = listed) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent, options and mode. - const record = { - sessionId: 'session', - provider: 'claude', - options, - permissionMode - } as AgentSessionRecord - return () => - structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog: catalog(rows) }, - record - ) - } - const stoppedAfterLaunch = async ( - launched: AgentChatPermissionMode, - current: () => Promise - ) => - (await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current)) - .stopAgent.mock.calls.length > 0 - - it('restarts Full access or Accept edits into Ask when auto is not confirmed', async () => { - const unconfirmed = [ - launch({ model: 'haiku', permissionMode: 'auto' }), - launch({ permissionMode: 'auto' }, [{ id: 'opus[1m]', isDefault: true }]) - ] - for (const autoPick of unconfirmed) { - await expect(autoPick()).resolves.toBe('ask') - expect(await stoppedAfterLaunch('full-access', autoPick)).toBe(true) - expect(await stoppedAfterLaunch('accept-edits', autoPick)).toBe(true) - expect(await stoppedAfterLaunch('ask', autoPick)).toBe(false) - } - }) - - it('never restarts a child it just started, from an alias or a resolved id', async () => { - const records: Record[] = [ - { permissionMode: 'auto' }, - { model: 'default', permissionMode: 'auto' }, - { model: 'opus[1m]', permissionMode: 'auto' }, - { model: 'sonnet', permissionMode: 'auto' }, - { model: 'haiku', permissionMode: 'auto' }, - { model: 'claude-opus-5-5[1m]', permissionMode: 'auto' } - ] - const runsAuto = [true, true, true, true, false, false] - for (const [index, options] of records.entries()) { - const current = launch(options) - const launched = await current() - expect(launched).toBe(runsAuto[index] ? 'auto' : 'ask') - expect(await stoppedAfterLaunch(launched!, current)).toBe(false) - } - }) - - // A resolved ID initially launches Ask; the reported catalog alias enables auto after one restart. - it('settles after one restart once a resolved id is reported as its alias', async () => { - const first = await launch({ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' })() - const reported = launch({ model: 'opus[1m]', permissionMode: 'auto' }) - expect(await stoppedAfterLaunch(first!, reported)).toBe(true) - expect(await stoppedAfterLaunch((await reported())!, reported)).toBe(false) - }) - }) - it('never reads the mode for a child launched with none', async () => { const current = now('full-access') expect((await retire({}, {}, undefined, current)).stopAgent).not.toHaveBeenCalled() diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index 14a3f2fbe85e..736e79eb6aca 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -3,8 +3,6 @@ import { AGENT_CHAT_PERMISSION_MODES, agentChatPermissionModes, chatModelSupportsAutoMode, - chatPermissionModeNeedsModelSupport, - effectiveChatPermissionMode, loadedChatPermissionSetting, newChatPermissionMode, offeredChatPermissionModes @@ -30,7 +28,7 @@ describe('agent chat permission modes', () => { it('seeds a new chat from the host setting, narrowed to what its agent offers', () => { expect(newChatPermissionMode('codex', 'accept-edits')).toBe('ask') expect(newChatPermissionMode('claude', 'accept-edits')).toBe('accept-edits') - // Claude's auto is narrowed at launch by the model, so the chat keeps its choice. + // Claude itself narrows auto by model, so the chat keeps its choice. expect(newChatPermissionMode('claude', 'auto')).toBe('auto') expect(newChatPermissionMode('codex', 'full-access')).toBe('full-access') expect(newChatPermissionMode('claude', 'retired-mode')).toBe('ask') @@ -41,29 +39,7 @@ describe('agent chat permission modes', () => { }) }) -describe('the mode a chat runs', () => { - it('runs Claude auto only on a model listed as supporting it', () => { - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', true)).toBe('auto') - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', false)).toBe('ask') - expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'auto')).toBe(true) - expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'ask')).toBe(false) - }) - - it('runs Codex auto on any model', () => { - expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'auto', false)).toBe('auto') - expect(chatPermissionModeNeedsModelSupport(CODEX_CHAT_PERMISSION_MODES, 'auto')).toBe(false) - }) - - it('asks for a chat saved before modes existed, or with a value its agent cannot run', () => { - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, undefined, true)).toBe('ask') - expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'accept-edits', true)).toBe( - 'ask' - ) - expect( - effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'bypassPermissions', true) - ).toBe('ask') - }) - +describe('the modes a chat offers', () => { it('offers Approve for me only where the model runs it', () => { expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, false)).toEqual([ 'ask', diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 2eaa968b842d..62a330fb0f0e 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -70,25 +70,6 @@ export function chatModelSupportsAutoMode( return row?.supportsAutoMode === true } -export function chatPermissionModeNeedsModelSupport( - support: AgentChatPermissionModeSupport, - intent: unknown -): boolean { - return ( - support.autoNeedsModelSupport === true && savedChatPermissionMode(support, intent) === 'auto' - ) -} - -/** Saved choice, with `auto` on a model not listed for it read as Ask; never written back. */ -export function effectiveChatPermissionMode( - support: AgentChatPermissionModeSupport, - intent: unknown, - autoSupported: boolean -): AgentChatPermissionMode { - const mode = savedChatPermissionMode(support, intent) - return mode === 'auto' && support.autoNeedsModelSupport && !autoSupported ? 'ask' : mode -} - export function offeredChatPermissionModes( support: AgentChatPermissionModeSupport, autoSupported: boolean