From cb4b3a51d07344e6d794290e3fff33d1effad743 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:27:00 -0700 Subject: [PATCH 1/7] feat(native-chat): per-chat permission modes for Claude and Codex chats Each Claude and Codex chat now has its own permission mode (Ask for approval, Accept edits, Approve for me, Full access), saved as one more chat option and picked from a button after "+" on desktop or a pill on the phone. The mode takes effect when the agent starts: Claude gets launch flags, Codex gets the thread policy on start and resume. A change restarts an idle agent before the next message, never during a reply. New chats take the host's default from Settings > Chat, seeded once from Agent Permissions. --- .../MobileNativeChatSessionOptionPickers.tsx | 52 +++- .../MobileNativeChatSessionOptionRows.tsx | 22 +- ...ured-agent-options.permission-mode.test.ts | 126 +++++++++ .../use-mobile-structured-agent-options.ts | 10 +- .../use-mobile-structured-agent-session.ts | 9 +- ...-mobile-structured-session-option-stamp.ts | 28 ++ .../acp/acp-structured-compaction.test.ts | 2 +- .../claude-agent-sdk-contract-pins.test.ts | 11 +- src/main/claude/claude-model-catalog-probe.ts | 3 + .../claude-structured-acquisition-launch.ts | 5 +- .../claude-structured-agent-definition.ts | 4 +- .../claude-structured-catalog-listing.ts | 3 +- ...-structured-effort-default-at-rest.test.ts | 2 +- ...laude-structured-launch-resolution.test.ts | 70 ++--- .../claude-structured-launch-resolution.ts | 38 ++- .../claude/claude-structured-model-catalog.ts | 3 + ...-structured-options-context-window.test.ts | 23 +- src/main/claude/claude-structured-options.ts | 35 ++- .../claude-structured-permission-mode.test.ts | 45 ---- .../claude-structured-permission-mode.ts | 26 -- .../claude/claude-structured-real-cli.test.ts | 54 ++-- .../claude-structured-session-adapter.test.ts | 17 +- .../claude/claude-structured-session-state.ts | 14 +- .../claude-structured-spawn-options.test.ts | 67 ++--- .../claude/claude-structured-spawn-options.ts | 52 +--- .../claude-supervised-stop-real-cli.test.ts | 2 +- .../codex-structured-agent-definition.ts | 4 +- ...codex-structured-launch-resolution.test.ts | 63 ++--- .../codex-structured-launch-resolution.ts | 18 +- ...codex-structured-permission-policy.test.ts | 89 ++----- .../codex-structured-permission-policy.ts | 75 +++--- .../codex-structured-real-cli-visuals.test.ts | 3 +- .../codex/codex-structured-session-acquire.ts | 5 +- .../codex/codex-structured-session-state.ts | 12 +- .../codex-structured-thread-open.test.ts | 41 +-- src/main/codex/codex-structured-turn-start.ts | 12 +- .../codex/codex-structured-visuals.test.ts | 5 +- .../agent-model-catalog-persistence.ts | 3 + .../structured-agent-definition.ts | 3 + .../structured-agent-session-acquisition.ts | 2 + .../structured-agent-session-attach-flow.ts | 5 + ...ured-agent-session-attach-orchestration.ts | 10 +- .../structured-agent-session-host-delivery.ts | 15 +- ...structured-agent-session-host-mutations.ts | 11 +- .../structured-agent-session-host-types.ts | 4 + .../structured-agent-session-host.ts | 2 +- ...ed-agent-session-launch-permission.test.ts | 113 ++++++++ ...uctured-agent-session-launch-permission.ts | 56 ++++ ...uctured-agent-session-options-read.test.ts | 4 +- .../structured-agent-session-options-read.ts | 35 ++- ...ured-agent-session-outgrown-child.test.ts} | 72 ++++- ...tructured-agent-session-outgrown-child.ts} | 32 ++- ...ured-agent-session-permission-mode.test.ts | 105 ++++++++ ...gent-session-registered-definition.test.ts | 6 +- ...-agent-session-startup-attempt-contract.ts | 3 + ...tructured-agent-session-startup-attempt.ts | 3 + ...d-agent-session-status-summary-equality.ts | 1 + ...structured-agent-session-status-summary.ts | 6 + ...native-chat-inline-visuals-setting.test.ts | 2 - .../normalize-loaded-global-settings.test.ts | 23 ++ .../normalize-loaded-global-settings.ts | 6 + ...tured-startup-fault-is-not-an-exit.test.ts | 4 +- ...startup-unanswered-control-request.test.ts | 21 +- ...structured-agent-session-create-support.ts | 7 +- .../runtime/orca-runtime-get-worktree-ps.ts | 8 - ...ctured-agent-session-create-intent.test.ts | 14 +- ...tured-agent-session-create-message.test.ts | 26 ++ .../structured-agent-session-create.ts | 15 +- src/main/runtime/runtime-client-settings.ts | 6 + src/main/runtime/runtime-store-contract.ts | 1 + .../structured-agent-runtime-registrations.ts | 6 - .../structured-agent-session-runtime.ts | 9 +- .../structured-claude-runtime-adapter.ts | 6 - .../native-chat/NativeChatComposer.tsx | 1 + .../native-chat/NativeChatComposerActions.tsx | 11 +- .../native-chat/NativeChatComposerField.tsx | 5 +- .../NativeChatPermissionModePicker.test.tsx | 251 ++++++++++++++++++ .../NativeChatPermissionModePicker.tsx | 157 +++++++++++ .../NativeChatSessionOptionPickers.tsx | 38 +-- .../NativeChatStructuredSession.tsx | 1 + .../StructuredAgentSessionStatusBridge.tsx | 30 ++- .../native-chat-permission-mode-labels.ts | 81 ++++++ ...native-chat-session-option-surface-call.ts | 31 +++ ...e-structured-agent-session-option-state.ts | 13 +- ...t-session-options.permission-mode.test.tsx | 115 ++++++++ .../use-structured-agent-session-options.ts | 2 + .../use-structured-agent-session.ts | 3 + .../settings/ChatPermissionSetting.test.tsx | 38 +++ .../settings/ChatPermissionSetting.tsx | 43 +++ ...hatSettingsSection.inline-visuals.test.tsx | 4 +- .../ChatSettingsSection.navigation.test.tsx | 33 +-- .../settings/ChatSettingsSection.test.tsx | 3 +- .../settings/ChatSettingsSection.tsx | 6 + .../settings/chat-naming-persistence.test.tsx | 7 +- .../settings/chat-permission-search.ts | 12 + .../settings-navigation-interface-sections.ts | 2 + .../src/i18n/en-runtime-required.json | 3 +- src/renderer/src/i18n/locales/en.json | 15 ++ src/renderer/src/i18n/locales/es.json | 15 ++ src/renderer/src/i18n/locales/fr.json | 15 ++ src/renderer/src/i18n/locales/ja.json | 15 ++ src/renderer/src/i18n/locales/ko.json | 15 ++ src/renderer/src/i18n/locales/zh.json | 15 ++ .../launch-structured-agent-session.test.ts | 4 +- .../lib/launch-structured-agent-session.ts | 4 +- ...structured-agent-session-launch-options.ts | 21 +- .../web-preference-normalization.ts | 11 + .../web/preload-api/web-preferences-store.ts | 8 +- src/shared/agent-chat-permission-mode.test.ts | 115 ++++++++ src/shared/agent-chat-permission-mode.ts | 104 ++++++++ .../agent-session-option-catalog-types.ts | 2 + src/shared/agent-session-wire.ts | 12 +- src/shared/claude-model-list-probe.ts | 6 +- src/shared/commit-message-agent-spec.ts | 2 + src/shared/commit-message-model-parsers.ts | 5 +- src/shared/default-global-settings.ts | 5 + src/shared/global-settings-types.ts | 4 + .../native-chat-session-option-defaults.ts | 33 +++ .../native-chat-session-option-snapshot.ts | 10 +- .../rpc-contract/client-settings-params.ts | 4 +- ...ructured-agent-session-discovered-model.ts | 1 + .../structured-agent-session-option-view.ts | 15 ++ .../structured-agent-session-options.ts | 25 +- ...ed-agent-session-permission-option.test.ts | 120 +++++++++ ...uctured-agent-session-permission-option.ts | 80 ++++++ 125 files changed, 2604 insertions(+), 662 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts create mode 100644 mobile/src/session/use-mobile-structured-session-option-stamp.ts delete mode 100644 src/main/claude/claude-structured-permission-mode.test.ts delete mode 100644 src/main/claude/claude-structured-permission-mode.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts rename src/main/native-chat/agent-session-wire/{structured-agent-session-signed-out-child.test.ts => structured-agent-session-outgrown-child.test.ts} (77%) rename src/main/native-chat/agent-session-wire/{structured-agent-session-signed-out-child.ts => structured-agent-session-outgrown-child.ts} (72%) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-surface-call.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx create mode 100644 src/renderer/src/components/settings/ChatPermissionSetting.test.tsx create mode 100644 src/renderer/src/components/settings/ChatPermissionSetting.tsx create mode 100644 src/renderer/src/components/settings/chat-permission-search.ts create mode 100644 src/shared/agent-chat-permission-mode.test.ts create mode 100644 src/shared/agent-chat-permission-mode.ts create mode 100644 src/shared/structured-agent-session-permission-option.test.ts create mode 100644 src/shared/structured-agent-session-permission-option.ts diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index 7a0ec85912fd..0838fe1f0db6 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -21,6 +21,7 @@ import { SessionOptionSummaryRow } from './MobileNativeChatSessionOptionRows' import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode' import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' export type MobileNativeChatSessionOptionPickersProps = { @@ -35,7 +36,12 @@ export type MobileNativeChatSessionOptionPickersProps = { sendInFlight?: boolean } -/** Combined model/session-option trigger and its mobile bottom drawer. */ +/** Full access reads in the warning colour wherever it is shown. */ +function permissionChoiceColor(value: string | undefined): string | undefined { + return value === 'full-access' ? colors.statusAmber : undefined +} + +/** The permission pill, then the combined model/session-option pill, sharing one bottom drawer. */ export function MobileNativeChatSessionOptionPickers({ controller, isWorking, @@ -49,12 +55,17 @@ export function MobileNativeChatSessionOptionPickers({ } const { snapshot, pendingId } = controller const model = snapshot.find((descriptor) => descriptor.category === 'model') + const permission = snapshot.find( + (descriptor) => descriptor.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + ) const options = sortNativeChatSessionOptions(snapshot) if (!model) { return null } - const disabled = isWorking || pendingId !== null || sendInFlight const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId) + const permissionView = activeDescriptor !== undefined && activeDescriptor === permission + // The permission mode applies from the next send, so a running turn never locks it. + const disabled = (!permissionView && isWorking) || pendingId !== null || sendInFlight const modelView = activeDescriptor?.id === model.id const modelLabel = mobileModelPillLabel(model) const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null @@ -91,12 +102,30 @@ export function MobileNativeChatSessionOptionPickers({ }) } + const permissionChoice = + permission?.kind.type === 'select' + ? permission.kind.choices.find((choice) => choice.value === permission.kind.currentValue) + : undefined + const pillsBusy = pendingId !== null || sendInFlight + return ( - + + {permission && permissionChoice ? ( + { + Keyboard.dismiss() + setOpenDescriptorId(permission.id) + }} + /> + ) : null} @@ -104,13 +133,15 @@ export function MobileNativeChatSessionOptionPickers({ [styles.sheetNav, pressed && styles.pressed]} - onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)} + onPress={ + modelView || permissionView ? closePicker : () => setOpenDescriptorId(model.id) + } hitSlop={8} > - {modelView ? ( + {modelView || permissionView ? ( ) : ( @@ -136,6 +167,7 @@ export function MobileNativeChatSessionOptionPickers({ grouped onSetOption={(value) => applyOption(activeDescriptor, value)} onInvokeAction={() => invokeAction(activeDescriptor)} + {...(permissionView ? { choiceLabelColor: permissionChoiceColor } : {})} /> {modelView && options.length > 0 ? ( @@ -160,6 +192,12 @@ export function MobileNativeChatSessionOptionPickers({ } const styles = StyleSheet.create({ + pills: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 1, + gap: spacing.xs + }, sheet: { paddingBottom: spacing.xs }, diff --git a/mobile/src/session/MobileNativeChatSessionOptionRows.tsx b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx index c4994113a33c..d84b0ca61256 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionRows.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx @@ -20,12 +20,14 @@ export function Pill({ label, accessibleName, disabled, - onPress + onPress, + labelColor }: { label: string accessibleName: string disabled: boolean onPress: () => void + labelColor?: string }): React.JSX.Element { return ( @@ -56,7 +62,8 @@ function ChoiceRow({ disabled, grouped, divided, - onPress + onPress, + labelColor }: { label: string description?: string @@ -65,6 +72,7 @@ function ChoiceRow({ grouped: boolean divided: boolean onPress: () => void + labelColor?: string }): React.JSX.Element { return ( : null} - {label} + {label} {description ? ( {description} @@ -198,13 +206,16 @@ export function DescriptorRows({ disabled, grouped = false, onSetOption, - onInvokeAction + onInvokeAction, + choiceLabelColor }: { descriptor: SessionOptionDescriptor disabled: boolean grouped?: boolean onSetOption: (value: SessionOptionValue) => void onInvokeAction: () => void + /** A choice whose name reads in a colour of its own, as Full access does. */ + choiceLabelColor?: (value: string) => string | undefined }): React.JSX.Element { const locked = disabled || !descriptor.settable // Why: flip-only without a baseline is an action — never claim On/Off. @@ -257,6 +268,7 @@ export function DescriptorRows({ grouped={grouped} divided={grouped && index < choices.length - 1} onPress={() => onSetOption(choice.value)} + labelColor={choiceLabelColor?.(choice.value)} /> ))} diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts new file mode 100644 index 000000000000..4dcc805e55b3 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -0,0 +1,126 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' +import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' + +function optionsFor(permissionMode?: string): AgentSessionOptionsResult { + return { + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + current: { model: 'gpt-live', ...(permissionMode ? { permissionMode } : {}) } + } +} + +/** Answers each options read with the next mode in `modes`, repeating the last. */ +function optionsClient(...modes: (string | undefined)[]) { + let reads = 0 + const client: RpcClient = { + sendRequest: async (method: string) => { + if (method === 'agentSession.modelCatalog') { + return { + id: 'rpc-1', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + const result = + method === 'agentSession.options' + ? optionsFor(modes[Math.min(reads++, modes.length - 1)]) + : {} + return { id: 'rpc-1', ok: true as const, result, _meta: { runtimeId: 'runtime-1' } } + }, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + return { client, reads: () => reads } +} + +type Controller = ReturnType +type ProbeProps = { client: RpcClient; fence: number; optionStamp: string } + +const mutate: StructuredAgentSessionMutate = vi.fn() + +async function mountOptions(props: ProbeProps) { + let rendered: Controller | null = null + function Probe(probe: ProbeProps): null { + rendered = useMobileStructuredAgentOptions({ + agent: 'codex', + client: probe.client, + sessionId: 'session-1', + enabled: true, + fence: probe.fence, + mutate, + optionStamp: probe.optionStamp + }) + return null + } + let renderer: ReactTestRenderer | null = null + const settle = () => + act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + await act(async () => { + renderer = create(createElement(Probe, props)) + }) + await settle() + return { + permission: () => { + const entry = (rendered as Controller | null)?.optionSnapshot.find( + (descriptor) => descriptor.id === 'permissionMode' + ) + return entry?.kind.type === 'select' ? entry.kind.currentValue : null + }, + rerender: async (next: Partial) => { + await act(async () => { + renderer?.update(createElement(Probe, { ...props, ...next })) + }) + await settle() + }, + unmount: () => act(async () => renderer?.unmount()) + } +} + +describe('the permission mode on the phone', () => { + it('re-reads once when the host status moves, so a desktop pick shows here', async () => { + const rpc = optionsClient('ask', 'full-access') + const options = await mountOptions({ + client: rpc.client, + fence: 1, + optionStamp: 'gpt-live|ask' + }) + expect(options.permission()).toBe('ask') + expect(rpc.reads()).toBe(1) + + await options.rerender({ optionStamp: 'gpt-live|full-access' }) + expect(options.permission()).toBe('full-access') + expect(rpc.reads()).toBe(2) + await options.unmount() + }) + + it('keeps the mode across a fence of the same chat', async () => { + const rpc = optionsClient('auto') + const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' }) + expect(options.permission()).toBe('auto') + await options.rerender({ fence: 2 }) + expect(options.permission()).toBe('auto') + await options.unmount() + }) + + it('shows no pill for a host that states no mode', async () => { + const rpc = optionsClient(undefined) + const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' }) + expect(rpc.reads()).toBe(1) + expect(options.permission()).toBeNull() + await options.unmount() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index c186ade0cdb1..f65c6760b1e1 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -50,8 +50,10 @@ export function useMobileStructuredAgentOptions(args: { enabled: boolean fence: number | null mutate: StructuredAgentSessionMutate + /** Moves when the host's status says a session option changed; each move re-reads them. */ + optionStamp?: string }): StructuredOptionsController { - const { agent, client, enabled, fence, mutate, sessionId } = args + const { agent, client, enabled, fence, mutate, optionStamp, sessionId } = args // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. const optionCatalog = useMemo( () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), @@ -89,10 +91,12 @@ export function useMobileStructuredAgentOptions(args: { const previous = optionStateRef.current const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) // A host answer is the account's, not the fence's: keep it rather than fall back to the placeholder. - const next = + const kept = sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin') ? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource } : seeded + // The chat's own, which a restart for a new mode keeps: the pill never blanks between them. + const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 pendingOptionRef.current = null optionStateRef.current = next @@ -141,7 +145,7 @@ export function useMobileStructuredAgentOptions(args: { return () => { stale = true } - }, [client, enabled, optionCatalog, sessionId, fence, updateOptionState]) + }, [client, enabled, optionCatalog, optionStamp, sessionId, fence, updateOptionState]) const optionSnapshot = useMemo( () => structuredAgentSessionOptionSnapshot(optionState), diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index ceb172306486..ec2514f0b88c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -25,6 +25,7 @@ import { useMobileStructuredSessionHostStopping } from './use-mobile-structured- import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' +import { useMobileStructuredSessionOptionStamp } from './use-mobile-structured-session-option-stamp' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { pendingStructuredPromptIdentity, @@ -91,13 +92,19 @@ export function useMobileStructuredAgentSession(args: { onSendError }) + const optionStamp = useMobileStructuredSessionOptionStamp({ + client, + sessionId, + enabled: enabled && connected && hostSupport?.statusFeed === true + }) const options = useMobileStructuredAgentOptions({ agent, client, sessionId, enabled, fence: state.fence, - mutate + mutate, + optionStamp }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = options diff --git a/mobile/src/session/use-mobile-structured-session-option-stamp.ts b/mobile/src/session/use-mobile-structured-session-option-stamp.ts new file mode 100644 index 000000000000..b8706be96ec5 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-option-stamp.ts @@ -0,0 +1,28 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +/** + * The option values the host's status carries for session `sessionId`, as one stamp: a pick made + * on another device moves it, so the phone re-reads the session's options, as the desktop does. + * Off `enabled` (no status feed or no connection), it never moves. + */ +export function useMobileStructuredSessionOptionStamp(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean +}): string { + const { client, sessionId, enabled } = args + const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readStamp = (): string => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + } + return useSyncExternalStore(subscribe, readStamp, readStamp) +} diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index 46a1802cdd44..297a076816ca 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -8,7 +8,7 @@ import { import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' -import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-signed-out-child' +import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-outgrown-child' import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index 65e536d6bd77..5dd419aba3ae 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -5,7 +5,6 @@ import { query, type CanUseTool, type Options, - type PermissionMode, type SDKUserMessage, type SpawnedProcess as SdkSpawnedProcess, type SpawnOptions as SdkSpawnOptions @@ -13,6 +12,7 @@ import { import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess } from '@orca/process-host' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' @@ -131,7 +131,7 @@ function recordingSpawner(spawns: SpawnSeen[]) { } } -function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = []) { +function resolvedLaunch(permissionMode: AgentChatPermissionMode, launchArgs: string[] = []) { const record = { sessionId: 'contract-pin-session', provider: 'claude', @@ -150,9 +150,8 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [ store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, - resolveAuthPolicy: () => ({ account: 'managed' }), - resolvePermissionMode: () => permissionMode - })({ identity: { sessionId: record.sessionId } as never }) + resolveAuthPolicy: () => ({ account: 'managed' }) + })({ identity: { sessionId: record.sessionId } as never, permissionMode }) } function singleUserTurn(): AsyncIterable { @@ -330,7 +329,7 @@ describe('Claude Agent SDK contract pins', () => { const spawns: SpawnSeen[] = [] // Driven by the real resolver, so the argv walk covers its option set and merge order, // not a hand-written options literal. - const launch = await resolvedLaunch('bypassPermissions', [ + const launch = await resolvedLaunch('full-access', [ '--model', 'claude-sonnet-4-5', '--dangerously-skip-permissions', diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 8dbaf3b6e73d..f464187ff5df 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -72,6 +72,9 @@ export function createClaudeModelCatalogProbe( })), ...(model.supportsFastMode !== undefined ? { supportsFastMode: model.supportsFastMode } + : {}), + ...(model.supportsAutoMode !== undefined + ? { supportsAutoMode: model.supportsAutoMode } : {}) })), origin: 'probe' diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 66f0bf0d6651..3dcf2a699536 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -89,7 +89,10 @@ export async function resolveClaudeAcquisitionLaunch(args: { } : input.identity const launch = await deps - .resolveLaunch({ identity: launchIdentity }) + .resolveLaunch({ + identity: launchIdentity, + ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}) + }) .catch((error: unknown) => { throw error instanceof AgentSessionPreSpawnError ? error diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts index 447ecb79a3be..36518bd61daa 100644 --- a/src/main/claude/claude-structured-agent-definition.ts +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -2,6 +2,7 @@ import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-sessi import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isClaudeStructuredOptionKey } from './claude-structured-options' import { claudeFallbackModelOptions } from './claude-structured-session-options' +import { CLAUDE_CHAT_PERMISSION_MODES } from '../../shared/agent-chat-permission-mode' export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'claude', @@ -23,5 +24,6 @@ export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { acceptsKey: isClaudeStructuredOptionKey, fallbackModels: claudeFallbackModelOptions, effortDefaultsToModel: true - } + }, + permissionModes: CLAUDE_CHAT_PERMISSION_MODES } diff --git a/src/main/claude/claude-structured-catalog-listing.ts b/src/main/claude/claude-structured-catalog-listing.ts index cbe22f35d5b8..c529ff920598 100644 --- a/src/main/claude/claude-structured-catalog-listing.ts +++ b/src/main/claude/claude-structured-catalog-listing.ts @@ -41,7 +41,8 @@ function wireClaudeModel(entry: ListedModel): WireClaudeModel { ...(entry.description ? { description: entry.description } : {}), isDefault: entry.isDefault, efforts: entry.efforts, - ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}) + ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}), + ...(entry.supportsAutoMode !== undefined ? { supportsAutoMode: entry.supportsAutoMode } : {}) } } diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index de9d569dd694..a2171d299b98 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -390,7 +390,7 @@ describe('Claude effort default at rest', () => { const result = await readAtRest(store, record) - expect(result.current).toEqual({ model: 'gpt-5.5' }) + expect(result.current).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' }) }) it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 963df6ce7699..a31f59e6cced 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -13,7 +13,6 @@ import { createClaudeStructuredLaunchResolver, type ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' -import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' import { CLAUDE_THINKING_DISPLAY_FLAG, type ClaudeCliFlag } from './claude-cli-flag-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -57,8 +56,6 @@ function resolverFor( value: AgentSessionRecord | null, resolveEnv?: () => Record, account: 'managed' | 'system' = 'system', - // Manual by default so a test that is not about permissions is not silently about them. - agentDefaultArgs: Record = { claude: '' }, hasTranscript: () => Promise = async () => true, resolveLaunchArgs?: () => string[], attachmentDirectory?: string @@ -68,7 +65,6 @@ function resolverFor( resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ account }), - resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), ...(resolveEnv ? { resolveEnv } : {}), @@ -117,6 +113,8 @@ describe('claude structured launch resolution', () => { supportedDialogKinds: [], extraArgs: { 'replay-user-messages': null }, systemPrompt: { type: 'preset', preset: 'claude_code' }, + // A start the host named no mode for asks. + permissionMode: 'default', sessionId: first.providerSessionId }) expect(first.options.resume).toBeUndefined() @@ -137,7 +135,6 @@ describe('claude structured launch resolution', () => { value, undefined, 'system', - undefined, hasTranscript )({ identity: identityAt('old-leaf') }) const expected = claudeSessionIdForOrcaSession(SESSION_ID, boundary.operationId) @@ -154,7 +151,6 @@ describe('claude structured launch resolution', () => { value, undefined, 'system', - undefined, hasTranscript )({ identity: IDENTITY }) expect(retry.providerSessionId).toBe(expected) @@ -162,7 +158,6 @@ describe('claude structured launch resolution', () => { { ...value, providerContextBoundary: { ...boundary, operationId: 'clear-two' } }, undefined, 'system', - undefined, async () => false )({ identity: IDENTITY }) expect(next.providerSessionId).not.toBe(expected) @@ -261,7 +256,6 @@ describe('claude structured launch resolution', () => { }), undefined, 'system', - { claude: '' }, hasTranscript )({ identity: identityAt(null) }) @@ -280,15 +274,11 @@ describe('claude structured launch resolution', () => { }) }) - // Agent Permissions is stored as the bypass flag inside the launch arguments, so presence of - // that flag — not the whole string — is what Yolo means, exactly as a terminal launch reads it. - it.each([ - ['--dangerously-skip-permissions'], - ['--dangerously-skip-permissions --model Opus'], - ['--model Opus --dangerously-skip-permissions'] - ])('starts a Yolo session in bypassPermissions for args %s', async (claude) => { - const launch = await resolverFor(record(), undefined, 'system', { claude })({ - identity: IDENTITY + // Full access is the owned bypass flag, never the SDK's typed option older binaries reject. + it('starts a Full access chat with the bypass flag and no permission mode', async () => { + const launch = await resolverFor(record())({ + identity: IDENTITY, + permissionMode: 'full-access' }) expect(launch.options.extraArgs).toEqual({ @@ -299,31 +289,30 @@ describe('claude structured launch resolution', () => { expect(launch.options.allowDangerouslySkipPermissions).toBeUndefined() }) - // The common profile: the toggle has never been used, so it has written nothing, and the - // default for the key it did not write is the bypass flag — the posture the terminal has - // always given these users. - it('starts a session that never opened Agent settings in bypassPermissions', async () => { - const launch = await resolverFor(record(), undefined, 'system', {})({ identity: IDENTITY }) + it.each([ + ['ask', 'default'], + ['accept-edits', 'acceptEdits'], + ['auto', 'auto'] + ] as const)('starts a %s chat in permission mode %s', async (permissionMode, expected) => { + const launch = await resolverFor(record())({ identity: IDENTITY, permissionMode }) - expect(launch.options.extraArgs).toEqual({ - 'replay-user-messages': null, - 'dangerously-skip-permissions': null - }) + expect(launch.options.permissionMode).toBe(expected) + expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) }) - // Manual is stored as an empty string, which owns the key and so beats the shipped default. - it.each([[''], ['--model Opus']])( - 'leaves a Manual session prompting for args %s', - async (claude) => { - const launch = await resolverFor(record(), undefined, 'system', { claude })({ - identity: IDENTITY - }) + // The chat's own mode replaces Agent Permissions: a Yolo terminal setting grants a chat nothing. + it('ignores the bypass flag the agent Arguments carry', async () => { + const launch = await resolverFor( + record(), + undefined, + 'system', + async () => true, + () => ['--dangerously-skip-permissions'] + )({ identity: IDENTITY, permissionMode: 'ask' }) - expect(launch.options.permissionMode).toBeUndefined() - expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) - expect(launch.options.allowDangerouslySkipPermissions).toBeUndefined() - } - ) + expect(launch.options.permissionMode).toBe('default') + expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) + }) it('passes configured arguments on start without taking over permission or session flags', async () => { const launch = await resolverFor( @@ -344,7 +333,7 @@ describe('claude structured launch resolution', () => { model: 'claude-sonnet-4-5', 'replay-user-messages': null }) - expect(launch.options.permissionMode).toBeUndefined() + expect(launch.options.permissionMode).toBe('default') expect(launch.options.sessionId).toBe(launch.providerSessionId) }) @@ -354,7 +343,6 @@ describe('claude structured launch resolution', () => { record({ ...RESUMABLE, launchArgs: ['--model', 'stale'] }), undefined, 'system', - { claude: '' }, async () => true, () => args ) @@ -414,7 +402,6 @@ describe('claude structured launch resolution', () => { record(), undefined, 'system', - { claude: '' }, async () => true, undefined, '/state/agent-session-attachments' @@ -429,7 +416,6 @@ describe('claude structured launch resolution', () => { record(), undefined, 'system', - { claude: '' }, async () => true, () => ['--add-dir', '/extra'], '/state/agent-session-attachments' diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index eb2cf04ae97b..d267643a33a1 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -6,6 +6,7 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provider-handle' import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -74,16 +75,24 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { extraArgs: { 'replay-user-messages': null } } +const CLAUDE_PERMISSION_MODES = { + ask: 'default', + 'accept-edits': 'acceptEdits', + auto: 'auto' +} as const satisfies Record, PermissionMode> + /** - * Agent Permissions as query-start options. + * A chat's permission mode as query-start options. * - * The owned CLI flag preserves the user-installed binary contract. The SDK's typed bypass option - * emits a newer allow flag that older Claude binaries reject before a structured session starts. + * Full access is the owned CLI flag, which preserves the user-installed binary contract: the SDK's + * typed bypass option emits a newer allow flag that older Claude binaries reject before starting. */ export function claudeStructuredPermissionOptions( - mode: PermissionMode -): Pick { - return mode === 'bypassPermissions' ? { extraArgs: { 'dangerously-skip-permissions': null } } : {} + mode: AgentChatPermissionMode +): Pick { + return mode === 'full-access' + ? { extraArgs: { 'dangerously-skip-permissions': null } } + : { permissionMode: CLAUDE_PERMISSION_MODES[mode] } } export type ClaudeStructuredLaunch = { @@ -118,8 +127,6 @@ export type ClaudeStructuredLaunchResolverDeps = { /** Required, so every caller states which login a failed sign-in names rather than inherit a * guess. Build it with claudeStructuredAuthPolicyForSettings. */ resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy - /** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */ - resolvePermissionMode?: () => Promise | PermissionMode /** The host's chat attachment store: files a client attached live there, outside the workspace, * and the agent reads them without asking. */ attachmentDirectory?: string @@ -163,10 +170,17 @@ export async function resolveClaudeStructuredInvocation( } } +/** What one start asks of the launch: the conversation, and the permission mode the host decided + * it runs, which absent asks. */ +export type ClaudeStructuredLaunchInput = { + identity: AgentSessionJournalIdentity + permissionMode?: AgentChatPermissionMode +} + export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps -): (input: { identity: AgentSessionJournalIdentity }) => Promise { - return async ({ identity }) => { +): (input: ClaudeStructuredLaunchInput) => Promise { + return async ({ identity, permissionMode }) => { const record = deps.store.getRecord(identity.sessionId) if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) @@ -218,9 +232,7 @@ export function createClaudeStructuredLaunchResolver( ...(deps.attachmentDirectory ? [deps.attachmentDirectory] : []), ...(visuals ? [visuals.visuals.folder] : []) ] - const permission = claudeStructuredPermissionOptions( - (await deps.resolvePermissionMode?.()) ?? 'default' - ) + const permission = claudeStructuredPermissionOptions(permissionMode ?? 'ask') // A start that failed before its first turn wrote no transcript, and `--resume` of an absent // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null diff --git a/src/main/claude/claude-structured-model-catalog.ts b/src/main/claude/claude-structured-model-catalog.ts index 8ea79eba3d52..c53a310db4b8 100644 --- a/src/main/claude/claude-structured-model-catalog.ts +++ b/src/main/claude/claude-structured-model-catalog.ts @@ -48,6 +48,8 @@ export function listedModels(value: unknown): ListedModel[] { const description = text(row.description) const supportsFastMode = typeof row.supportsFastMode === 'boolean' ? row.supportsFastMode : undefined + const supportsAutoMode = + typeof row.supportsAutoMode === 'boolean' ? row.supportsAutoMode : undefined return [ { id, @@ -56,6 +58,7 @@ export function listedModels(value: unknown): ListedModel[] { isDefault: resolvedModel !== null && resolvedModel === defaultResolvedModel, efforts: listedEfforts(row), ...(supportsFastMode !== undefined ? { supportsFastMode } : {}), + ...(supportsAutoMode !== undefined ? { supportsAutoMode } : {}), resolvedModel } ] diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index fb901fb90852..9416bf947a50 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -11,14 +11,10 @@ import type { ClaudeSession } from './claude-structured-session-state' function ringSession(catalog: unknown[] = []) { const session = sessionFor() const setModel = vi.fn(async () => undefined) - const setPermissionMode = vi.fn( - async () => undefined - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: supplies every connection member a model or permission-mode write and its catalog read call. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: supplies every connection member a model write and its catalog read call. session.connection = { ...session.connection, setModel, - setPermissionMode, supportedModels: async () => catalog } as ClaudeSession['connection'] const state = journal() @@ -54,25 +50,16 @@ describe('the context ring after a session option write', () => { expect(s.respond('turn-b', 2_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) - it('implies no window for a model the child refused or a mode the launch left out', async () => { + it('implies no window for a model the child refused', async () => { const refused = ringSession() refused.setModel.mockRejectedValueOnce(new ClaudeControlRequestError('set_model', 'refused')) await expect(refused.write('model', 'opus[1m]')).rejects.toThrow() expect(refused.respond('turn-a', 1_000)).toBeNull() - - const skipped = ringSession() - adoptClaudeStructuredSpawnOptions(skipped.session, { - options: new Map([['model', 'sonnet[1m]']]), - skipped: ['permissionMode'], - fastModeAtStart: false - }) - expect(skipped.respond('turn-a', 1_000)).toBeNull() }) - it('holds estimates after a permission-mode write even with a model written before it', async () => { + // The permission mode is a launch flag: a live write of it is refused like any unknown option. + it('refuses a live permission-mode write', async () => { const s = ringSession() - await s.write('model', 'sonnet[1m]') - await s.write('permissionMode', 'plan') - expect(s.respond('turn-a', 1_000)).toBeNull() + await expect(s.write('permissionMode', 'plan')).rejects.toThrow(/permissionMode/) }) }) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 15f33ea1b01f..6aa1ae8cc0e7 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -1,4 +1,4 @@ -import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { @@ -13,7 +13,7 @@ import { import type { ClaudeSession } from './claude-structured-session-state' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' -const OPTION_ORDER = ['model', 'effort', 'fastMode', 'permissionMode'] as const +const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const /** * Efforts the settings readback cannot report. `max` applies for the rest of the @@ -23,9 +23,6 @@ const OPTION_ORDER = ['model', 'effort', 'fastMode', 'permissionMode'] as const */ const UNREPORTED_EFFORTS: ReadonlySet = new Set(['max']) -/** Writes that can move the main thread to another model or window: `opusplan` runs plan mode on Opus. */ -const CONTEXT_WINDOW_KEYS: ReadonlySet = new Set(['model', 'permissionMode']) - export function restoredClaudeStructuredSessionOptions( options: Readonly> | undefined ): Map { @@ -38,7 +35,7 @@ export function restoredClaudeStructuredSessionOptions( } /** The keys `setClaudeStructuredOption` writes; a pick made at rest is checked against these. */ -const CLAUDE_STRUCTURED_OPTION_KEYS = new Set(['model', 'permissionMode', 'effort', 'fastMode']) +const CLAUDE_STRUCTURED_OPTION_KEYS = new Set(['model', 'effort', 'fastMode']) export function isClaudeStructuredOptionKey(key: string): boolean { return CLAUDE_STRUCTURED_OPTION_KEYS.has(key) @@ -74,17 +71,15 @@ export async function setClaudeStructuredOption( const apply = input.key === 'model' ? () => session.connection.setModel(input.value, { timeoutMs }) - : input.key === 'permissionMode' - ? () => session.connection.setPermissionMode(input.value as PermissionMode, { timeoutMs }) - : input.key === 'effort' - ? () => - session.connection.applyFlagSettings( - { effortLevel: input.value as EffortLevel }, - { timeoutMs } - ) - : input.key === 'fastMode' && typeof fastMode === 'boolean' - ? () => session.connection.applyFlagSettings({ fastMode }, { timeoutMs }) - : null + : input.key === 'effort' + ? () => + session.connection.applyFlagSettings( + { effortLevel: input.value as EffortLevel }, + { timeoutMs } + ) + : input.key === 'fastMode' && typeof fastMode === 'boolean' + ? () => session.connection.applyFlagSettings({ fastMode }, { timeoutMs }) + : null if (!apply) { throw new AgentSessionOptionRejectedError( `claude stream-json has no session option named ${input.key}` @@ -148,8 +143,8 @@ export async function setClaudeStructuredOption( const mutationSequence = ++session.optionMutationSequence // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. const held = session.options.get(input.key) - // Only a model write can stale the model report — an effort or permission-mode - // write does not change what the child is running. Leaving the stamp behind + // Only a model write can stale the model report — an effort or Fast write does + // not change what the child is running. Leaving the stamp behind // would drop the session back to the written model and refuse, on the next // effort write, a level the model actually running advertises. if (modelWasConfirmed && input.key !== 'model') { @@ -158,7 +153,7 @@ export async function setClaudeStructuredOption( try { await apply() // Ahead of the fence checks: the child applied this write even if a newer one supersedes its bookkeeping. - if (CONTEXT_WINDOW_KEYS.has(input.key) && input.value !== held) { + if (input.key === 'model' && input.value !== held) { session.translator?.modelMayHaveChanged() } if (input.key === 'model') { diff --git a/src/main/claude/claude-structured-permission-mode.test.ts b/src/main/claude/claude-structured-permission-mode.test.ts deleted file mode 100644 index c09df8f5e057..000000000000 --- a/src/main/claude/claude-structured-permission-mode.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' - -describe('claudeStructuredPermissionModeForSettings', () => { - // The three states the Agent Permissions toggle can leave behind. The untouched case is the - // common one and the easiest to get wrong: the toggle writes nothing until it is used, and the - // default Orca ships for the key it did not write is the bypass flag — which is what a terminal - // launch has always applied to an untouched profile. - it('bypasses when the user has never opened Agent settings', () => { - expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: {} })).toBe( - 'bypassPermissions' - ) - expect(claudeStructuredPermissionModeForSettings({})).toBe('bypassPermissions') - expect(claudeStructuredPermissionModeForSettings(null)).toBe('bypassPermissions') - expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { codex: '' } })).toBe( - 'bypassPermissions' - ) - }) - - it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => { - for (const claude of [ - '--dangerously-skip-permissions', - '--dangerously-skip-permissions --model Opus', - '--model Opus --dangerously-skip-permissions' - ]) { - expect( - claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude } }), - claude - ).toBe('bypassPermissions') - } - }) - - // Manual is stored as an empty string, which owns the key and so beats the shipped default. - it('prompts when Manual cleared the flag', () => { - expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '' } })).toBe( - 'default' - ) - }) - - it('prompts when the user replaced the flag with something else', () => { - expect( - claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '--model Opus' } }) - ).toBe('default') - }) -}) diff --git a/src/main/claude/claude-structured-permission-mode.ts b/src/main/claude/claude-structured-permission-mode.ts deleted file mode 100644 index 2b7dcbebe6e6..000000000000 --- a/src/main/claude/claude-structured-permission-mode.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' -import type { GlobalSettings } from '../../shared/global-settings-types' -import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults' - -/** - * The Agent Permissions setting as the SDK's own permission mode. - * - * Read per acquisition — like the environment overlay and the auth policy beside it — rather than - * latched into the session record: the setting is the one copy of this fact, so nothing can - * disagree with it and a failed restore cannot silently downgrade a session to prompting. - * - * Yolo still stores itself as the agent's bypass flag inside the launch arguments, which is also - * what a terminal launch acts on, so presence of that flag is the fact to read — resolved through - * the same default fallback the terminal uses, which is why an untouched profile bypasses. The - * rest of the arguments string is a terminal concern this path does not interpret. - */ -export function claudeStructuredPermissionModeForSettings( - settings: - | Partial> - | null - | undefined -): PermissionMode { - return resolvedTuiAgentArgsBypassPermissions('claude', settings, process.platform) - ? 'bypassPermissions' - : 'default' -} diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 96a8c7a1803d..7d2b564716b0 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -7,7 +7,10 @@ import { buildClaudeChildProcessEnv } from './claude-child-process-environment' import * as claudeConnection from './claude-stream-json-connection' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeStructuredPermissionOptions +} from './claude-structured-launch-resolution' import { readClaudeInit } from './claude-structured-init-proof' import { realClaudeAuthenticated, @@ -38,17 +41,25 @@ function realAdapter( waitsForStartup = true ): ClaudeStructuredSessionAdapter { const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: command, - options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId }, - cwd, - env, - claudeConfigDir, - providerSessionId, - resumeLeafUuid: null, - resumesTranscript: false, - continuesChain: false - }), + resolveLaunch: async ({ permissionMode }) => { + const permission = claudeStructuredPermissionOptions(permissionMode ?? 'ask') + return { + pathToClaudeCodeExecutable: command, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + ...permission, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs }, + sessionId: providerSessionId + }, + cwd, + env, + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + } + }, onEvent: (event) => events.push(event), ...(onDispatchSettledLate ? { onDispatchSettledLate } : {}), readProcessStartTime: async () => 1, @@ -399,11 +410,15 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 120_000 ) - // The contract a chat's first message depends on: saved options ride the launch, and the - // message is written as soon as the child is published, before the CLI answers initialize. - it.skipIf(!realClaudeAuthenticated)( - 'runs a message written before initialize answers, under the saved options it was launched with', - async () => { + // The contract a chat's first message depends on: saved options and the chat's permission mode + // ride the launch, and the message is written as soon as the child is published, before the CLI + // answers initialize. + it.skipIf(!realClaudeAuthenticated).each([ + ['accept-edits', 'acceptEdits'], + ['full-access', 'bypassPermissions'] + ] as const)( + 'runs a message written before initialize answers, launched with saved options and %s', + async (permissionMode, cliPermissionMode) => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') const events: ClaudeStructuredSessionEvent[] = [] @@ -424,7 +439,8 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-saved-options', - options: { model: 'sonnet', permissionMode: 'plan', effort: 'low' } + options: { model: 'sonnet', effort: 'low' }, + permissionMode }) const dispatched = await adapter.dispatch({ sessionId: 'real-cli-handshake', @@ -453,7 +469,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { // The turn's own init names what the child was launched with. expect(messages().find((m) => m.type === 'system' && m.subtype === 'init')).toMatchObject({ model: 'claude-sonnet-5', - permissionMode: 'plan' + permissionMode: cliPermissionMode }) } finally { await adapter.closeAll() diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 0897917718da..8dedeafb859b 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -234,19 +234,18 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) }) - it.each([ - ['effort', { effort: 'retired-effort' }], - ['permissionMode', { permissionMode: 'retired-mode' }] - ] as const)('self-heals a persisted %s the CLI would refuse at launch', async (key, options) => { + // The chat's permission mode is the start's, never a saved option the CLI is handed. + it('self-heals a persisted effort the CLI would refuse, and launches the start mode', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) + const resolveLaunch = vi.spyOn(adapter['deps'], 'resolveLaunch') + const options = { effort: 'retired-effort', permissionMode: 'full-access' } + const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options } - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }) - ).resolves.toBeDefined() - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + await adapter.acquire({ ...start, permissionMode: 'accept-edits' }) + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['effort']) expect(claude.connections[0].launch.options).not.toHaveProperty('effort') - expect(claude.connections[0].launch.options).not.toHaveProperty('permissionMode') + expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits') }) it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 000626f46377..4e8a29e2a011 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,10 +1,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../shared/agent-session-journal-types' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionOptionsReportedEvent, @@ -15,7 +12,10 @@ import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import type { + ClaudeStructuredLaunch, + ClaudeStructuredLaunchInput +} from './claude-structured-launch-resolution' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { cancelProcessAcquisition } from '@orca/process-host/cancel-process-acquisition' @@ -98,9 +98,7 @@ export type ClaudeLateDispatchOutcome = export type ClaudeStructuredSessionAdapterDeps = { /** The `/` surface of a chat whose Claude is not running. */ atRestCommands?: ClaudeAtRestCommandCatalog - resolveLaunch: (input: { - identity: AgentSessionJournalIdentity - }) => Promise + resolveLaunch: (input: ClaudeStructuredLaunchInput) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts index 42780ae49986..d155d423c957 100644 --- a/src/main/claude/claude-structured-spawn-options.test.ts +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -19,28 +19,22 @@ function launched( }) } -const BYPASS_LAUNCH: ClaudeStructuredSdkOptions = { +const FULL_ACCESS_LAUNCH: ClaudeStructuredSdkOptions = { ...CLAUDE_STRUCTURED_BASE_OPTIONS, extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, - ...claudeStructuredPermissionOptions('bypassPermissions').extraArgs + ...claudeStructuredPermissionOptions('full-access').extraArgs } } describe('a Claude chat launched with its saved options', () => { - it('passes the saved model, effort, Fast and permission mode as launch options', () => { - const spawn = launched({ - model: 'opus', - effort: 'high', - fastMode: 'true', - permissionMode: 'plan' - }) + it('passes the saved model, effort and Fast as launch options', () => { + const spawn = launched({ model: 'opus', effort: 'high', fastMode: 'true' }) expect(spawn.sdkOptions).toMatchObject({ model: 'opus', effort: 'high', settings: { fastMode: true }, - permissionMode: 'plan', // The base launch is kept whole. includePartialMessages: true, extraArgs: { 'replay-user-messages': null } @@ -48,8 +42,7 @@ describe('a Claude chat launched with its saved options', () => { expect(Object.fromEntries(spawn.options)).toEqual({ model: 'opus', effort: 'high', - fastMode: 'true', - permissionMode: 'plan' + fastMode: 'true' }) expect(spawn.skipped).toEqual([]) }) @@ -66,13 +59,12 @@ describe('a Claude chat launched with its saved options', () => { } ) - it('leaves out an effort or permission mode no Claude can parse, and Fast it cannot decode', () => { - const spawn = launched({ effort: 'ludicrous', permissionMode: 'retired-mode', fastMode: 'yes' }) + it('leaves out an effort no Claude can parse, and Fast it cannot decode', () => { + const spawn = launched({ effort: 'ludicrous', fastMode: 'yes' }) expect(spawn.sdkOptions).not.toHaveProperty('effort') - expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') expect(spawn.sdkOptions).not.toHaveProperty('settings') - expect(spawn.skipped).toEqual(['effort', 'fastMode', 'permissionMode']) + expect(spawn.skipped).toEqual(['effort', 'fastMode']) }) it('keeps a saved Fast on for a new conversation, for its start to apply', () => { @@ -128,38 +120,15 @@ describe('a Claude chat launched with its saved options', () => { } ) - it('launches a saved bypass under an Agent Permissions bypass with the owned bypass flag', () => { - const spawn = launched({ permissionMode: 'bypassPermissions' }, { base: BYPASS_LAUNCH }) - - expect(spawn.sdkOptions.extraArgs).toEqual({ - 'replay-user-messages': null, - 'dangerously-skip-permissions': null - }) - expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') - expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') - }) - - it('never widens the Agent Permissions setting to a saved bypass', () => { - const spawn = launched({ permissionMode: 'bypassPermissions' }) - - expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) - expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') - expect(spawn.skipped).toEqual(['permissionMode']) - }) + // The chat's permission mode is the launch's, never a saved option: the record keeps it whole. + it.each(['full-access', 'plan', 'bypassPermissions'])( + 'leaves the launch permission alone with a saved permission mode %s', + (permissionMode) => { + const spawn = launched({ permissionMode }, { base: FULL_ACCESS_LAUNCH }) - // Known limit: the allow flag that would keep bypass reachable is one older CLIs reject at start. - it('starts a saved narrower mode under an Agent Permissions bypass without any bypass flag', () => { - const spawn = launched({ permissionMode: 'acceptEdits' }, { base: BYPASS_LAUNCH }) - - expect(spawn.sdkOptions.permissionMode).toBe('acceptEdits') - expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') - expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) - }) - - it('starts a saved narrower mode without the allow flag when Agent Permissions prompts', () => { - const spawn = launched({ permissionMode: 'plan' }) - - expect(spawn.sdkOptions.permissionMode).toBe('plan') - expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') - }) + expect(spawn.sdkOptions).toEqual(FULL_ACCESS_LAUNCH) + expect(spawn.options.size).toBe(0) + expect(spawn.skipped).toEqual([]) + } + ) }) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index be3414358646..b86b4ebfba50 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -2,7 +2,7 @@ // them, so its first message can be written at once instead of waiting for initialize to answer and // a control request to apply each one. -import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' import type { ClaudeStructuredLaunch, @@ -18,33 +18,6 @@ const EFFORT_LEVELS: ReadonlySet = new Set([ 'xhigh', 'max' ]) -const PERMISSION_MODES: ReadonlySet = new Set([ - 'default', - 'acceptEdits', - 'bypassPermissions', - 'plan', - 'dontAsk', - 'auto' -]) - -/** Whether `options` launch with the Agent Permissions bypass. */ -function claudeStructuredOptionsBypassPermissions(options: ClaudeStructuredSdkOptions): boolean { - return options.extraArgs?.['dangerously-skip-permissions'] !== undefined -} - -/** `options` launched in `mode` instead of the mode they carry; never more than they allow. */ -function claudeStructuredOptionsWithPermissionMode( - options: ClaudeStructuredSdkOptions, - mode: PermissionMode -): ClaudeStructuredSdkOptions { - if (mode === 'bypassPermissions') { - return options - } - // Known limit: no switch back to bypass later; that needs the allow flag older CLIs reject. - const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} - return { ...options, permissionMode: mode, extraArgs } -} - /** The saved value stands in for the agent Arguments' own flag for it: the chat's pick wins, and * the CLI is never handed the flag twice. */ function withoutConfiguredFlag( @@ -62,10 +35,6 @@ function isEffortLevel(value: string): value is EffortLevel { return EFFORT_LEVELS.has(value) } -function isPermissionMode(value: string): value is PermissionMode { - return PERMISSION_MODES.has(value) -} - export type ClaudeStructuredSpawnOptions = { sdkOptions: ClaudeStructuredSdkOptions /** The chat's options as the session holds them: what was launched, and a Fast the start applies. */ @@ -124,20 +93,6 @@ export function claudeStructuredSpawnOptions(input: { } } } - const permissionMode = saved.get('permissionMode') - if (permissionMode !== undefined) { - // Bypass is the Agent Permissions setting's to grant; a saved pick never widens it. - if ( - isPermissionMode(permissionMode) && - (permissionMode !== 'bypassPermissions' || - claudeStructuredOptionsBypassPermissions(input.launch.options)) - ) { - options.set('permissionMode', permissionMode) - sdkOptions = claudeStructuredOptionsWithPermissionMode(sdkOptions, permissionMode) - } else { - skipped.push('permissionMode') - } - } return { sdkOptions, options, skipped, fastModeAtStart } } @@ -158,9 +113,4 @@ export function adoptClaudeStructuredSpawnOptions( if (model !== undefined) { session.translator?.modelWritten(model) } - // The journal's window was measured under a mode this child did not take (`opusplan`'s plan - // mode runs Opus); a launch never leaves a model out. - if (spawn.skipped.includes('permissionMode')) { - session.translator?.modelMayHaveChanged() - } } diff --git a/src/main/claude/claude-supervised-stop-real-cli.test.ts b/src/main/claude/claude-supervised-stop-real-cli.test.ts index 48f8f1fb4e48..0f21a943caea 100644 --- a/src/main/claude/claude-supervised-stop-real-cli.test.ts +++ b/src/main/claude/claude-supervised-stop-real-cli.test.ts @@ -80,7 +80,7 @@ async function until(read: () => Promise | T | null, what: string, async function open(sessionId: string, cwd: string, resume: boolean, frames: Frame[]) { const settings = process.env.ORCA_REAL_CLAUDE_SETTINGS - const permission = claudeStructuredPermissionOptions('bypassPermissions') + const permission = claudeStructuredPermissionOptions('full-access') const connection = await openClaudeStreamJsonConnection( { pathToClaudeCodeExecutable: CLAUDE_BIN, diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts index 1f06c603000b..0390aa5b033a 100644 --- a/src/main/codex/codex-structured-agent-definition.ts +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -1,6 +1,7 @@ import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isCodexTurnOptionKey } from './codex-structured-turn-start' +import { CODEX_CHAT_PERMISSION_MODES } from '../../shared/agent-chat-permission-mode' export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'codex', @@ -24,5 +25,6 @@ export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { fallbackModels: () => null, // A running child answers only the effort its thread reported, never the model's default. effortDefaultsToModel: false - } + }, + permissionModes: CODEX_CHAT_PERMISSION_MODES } diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 817e98defe79..f4bbdda0f6b2 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -7,7 +7,6 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' -import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const { isWindowsProcessStartTimeAvailable } = vi.hoisted(() => ({ @@ -54,7 +53,6 @@ function resolverFor( value: AgentSessionRecord | null, resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, resolveRollout: () => Promise = async () => null, - agentDefaultArgs: Record = { codex: '' }, resolveLaunchArgs?: () => string[] ) { return createCodexStructuredLaunchResolver({ @@ -62,8 +60,7 @@ function resolverFor( resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, - resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), - resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs }) + resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []) }) } @@ -90,7 +87,6 @@ describe('codex structured launch resolution', () => { }), undefined, undefined, - { codex: '' }, () => args ) await expect(resolve({ identity: IDENTITY })).rejects.toThrow(/Arguments/) @@ -164,8 +160,12 @@ describe('codex structured launch resolution', () => { cwd: '/repos/workspace-1', codexHome: '/home/work/.codex', resumeThreadId: null, - // Every launch now carries a posture; neither one is left for config.toml to decide. - permissionPolicy: { approvalPolicy: 'on-request', sandbox: 'workspace-write' } + // Every launch carries a posture; none is left for config.toml to decide. No mode asks. + permissionPolicy: { + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + approvalsReviewer: 'user' + } }) }) @@ -248,38 +248,33 @@ describe('codex structured launch resolution', () => { }) // app-server owns the permission posture on the thread RPC, not process flags. - it('resolves the bypass posture as app-server thread policy', async () => { - const launch = await resolverFor(record(), undefined, undefined, { - codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol' - })({ identity: IDENTITY }) + it.each([ + [ + 'ask', + { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'user' } + ], + [ + 'auto', + { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'auto_review' } + ], + [ + 'full-access', + { approvalPolicy: 'never', sandbox: 'danger-full-access', approvalsReviewer: 'user' } + ] + ] as const)('opens a %s chat with its thread policy', async (permissionMode, policy) => { + const launch = await resolverFor(record())({ identity: IDENTITY, permissionMode }) expect(launch.args).toEqual(['app-server']) - expect(launch.permissionPolicy).toEqual({ - approvalPolicy: 'never', - sandbox: 'danger-full-access' - }) + expect(launch.permissionPolicy).toEqual(policy) }) - it('bypasses approvals for a profile that never opened Agent settings', async () => { - const launch = await resolverFor(record(), undefined, undefined, {})({ identity: IDENTITY }) - - expect(launch.args).toEqual(['app-server']) - expect(launch.permissionPolicy).toEqual({ - approvalPolicy: 'never', - sandbox: 'danger-full-access' - }) - }) + // The chat's mode replaces Agent Permissions: a Yolo terminal setting grants a chat nothing. + it('ignores the bypass flag the agent Arguments carry', async () => { + const launch = await resolverFor(record(), undefined, undefined, () => [ + '--dangerously-bypass-approvals-and-sandbox' + ])({ identity: IDENTITY, permissionMode: 'ask' }) - // Stated, not omitted: app-server resolves an absent field through the mirrored config.toml, - // so a Manual session on a home carrying `approval_policy = "never"` never prompted at all. - it('states the approval posture under Manual', async () => { - const launch = await resolverFor(record())({ identity: IDENTITY }) - - expect(launch.args).toEqual(['app-server']) - expect(launch.permissionPolicy).toEqual({ - approvalPolicy: 'on-request', - sandbox: 'workspace-write' - }) + expect(launch.permissionPolicy).toMatchObject({ approvalPolicy: 'on-request' }) }) // A thread opened on the configured default and then given a turn on the saved model reads to diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 8016c938ae83..2fdc1e88f103 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -6,15 +6,17 @@ // must name the thread this session actually proved — never one a caller asks // for, which is how a resume becomes a fork wearing a resume's name. -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' -import type { CodexStructuredLaunch } from './codex-structured-session-adapter' -import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' +import type { + CodexStructuredLaunch, + CodexStructuredLaunchInput +} from './codex-structured-session-state' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' @@ -31,9 +33,6 @@ export type CodexStructuredLaunchResolverDeps = { /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise resolveRollout?: typeof resolvePinnedCodexRolloutProof - /** The user's Agent Permissions setting as thread policy, re-read per acquisition. - * States both postures outright — a resume inherits the last one for any field left absent. */ - resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy /** This chat's visuals folder and skill; absent or null ⇒ the chat gets neither. */ prepareVisuals?: PrepareNativeChatVisuals } @@ -65,8 +64,8 @@ export async function resolveCodexStructuredInvocation( export function createCodexStructuredLaunchResolver( deps: CodexStructuredLaunchResolverDeps -): (input: { identity: AgentSessionJournalIdentity }) => Promise { - return async ({ identity }) => { +): (input: CodexStructuredLaunchInput) => Promise { + return async ({ identity, permissionMode }) => { const record = deps.store.getRecord(identity.sessionId) if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) @@ -90,7 +89,6 @@ export function createCodexStructuredLaunchResolver( } const { command, environment } = await resolveCodexStructuredInvocation(deps) const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs()) - const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) // A Codex record's chain holds only Codex handles; the attach admission refuses anything else. const resumeThreadId = head?.handle.nativeId ?? null @@ -109,7 +107,7 @@ export function createCodexStructuredLaunchResolver( // Only a thread this session created may still be one Codex never saved: a resumed, // forked or adopted head names a conversation Codex held. ...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}), - ...(permissionPolicy ? { permissionPolicy } : {}), + permissionPolicy: codexStructuredPermissionPolicy(permissionMode ?? 'ask'), ...(model ? { model } : {}), ...(visuals ? { visuals } : {}), ...(resumeThreadId diff --git a/src/main/codex/codex-structured-permission-policy.test.ts b/src/main/codex/codex-structured-permission-policy.test.ts index 67d08f3f109c..4c49a1259787 100644 --- a/src/main/codex/codex-structured-permission-policy.test.ts +++ b/src/main/codex/codex-structured-permission-policy.test.ts @@ -1,75 +1,42 @@ import { describe, expect, it } from 'vitest' -import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' -const BYPASS = { approvalPolicy: 'never', sandbox: 'danger-full-access' } -// Approvals on, writes confined to the workspace. Verified against codex 0.153.4: both values are -// accepted on thread/start and thread/resume, and the reply echoes them back as the effective -// policy even when the home's config.toml asks for `never` / `danger-full-access`. -const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } +const FULL_ACCESS = { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + approvalsReviewer: 'user' +} +const ASK = { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'user' } -describe('codexStructuredPermissionPolicyForSettings', () => { - it('bypasses when the user has never opened Agent settings', () => { - expect(codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: {} })).toEqual(BYPASS) - expect(codexStructuredPermissionPolicyForSettings({})).toEqual(BYPASS) - expect(codexStructuredPermissionPolicyForSettings(null)).toEqual(BYPASS) - expect( - codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { claude: '' } }) - ).toEqual(BYPASS) +describe('codexStructuredPermissionPolicy', () => { + it('maps Full access to no prompts and no sandbox', () => { + expect(codexStructuredPermissionPolicy('full-access')).toEqual(FULL_ACCESS) }) - it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => { - for (const codex of [ - '--dangerously-bypass-approvals-and-sandbox', - '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol', - '--model gpt-5.6-sol --dangerously-bypass-approvals-and-sandbox' - ]) { - expect( - codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex } }), - codex - ).toEqual(BYPASS) - } - }) - - it('keeps quoted mentions and operands after -- in Manual', () => { - for (const codex of [ - '--config "note=--dangerously-bypass-approvals-and-sandbox only as text"', - '-- --dangerously-bypass-approvals-and-sandbox' - ]) { - expect( - codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex } }), - codex - ).toEqual(MANUAL) - } + // The thread open spreads this, so an omitted field is absent, not reset: a thread resumed from + // Full access would keep `never`. Ask states every field, the human reviewer too. + it('states every Ask field, the human reviewer too', () => { + expect(codexStructuredPermissionPolicy('ask')).toEqual(ASK) }) - // Why an explicit policy rather than nothing: the thread-open path spreads this, so nothing - // means the fields are ABSENT, and absent is not a reset. A session flipped Yolo → Manual - // resumed with the Yolo thread's `approvalPolicy: never` still in force and escalated with no - // prompt. Manual has to say what it wants. - it('states the approval posture when Manual cleared the flag', () => { - expect(codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex: '' } })).toEqual( - MANUAL - ) + it('routes Approve for me to Codex auto-review under the Ask sandbox', () => { + expect(codexStructuredPermissionPolicy('auto')).toEqual({ + ...ASK, + approvalsReviewer: 'auto_review' + }) }) - it('never answers with an absent policy for either posture', () => { - for (const codex of ['', '--dangerously-bypass-approvals-and-sandbox', '--model gpt-5.6-sol']) { - expect( - codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex } }), - codex - ).toBeDefined() + // A sandbox mode, never a `sandboxPolicy` object: Codex folds the user's own writable roots and + // network access into a mode, and a policy object would replace them. + it('names a sandbox mode for every mode', () => { + for (const mode of ['ask', 'auto', 'full-access'] as const) { + expect(codexStructuredPermissionPolicy(mode)).not.toHaveProperty('sandboxPolicy') + expect(typeof codexStructuredPermissionPolicy(mode).sandbox).toBe('string') } }) - // The passthrough that used to carry these to app-server is gone on purpose; only the - // permission posture is derived, and nothing else from the field reaches argv. - it('carries nothing but the permission posture out of the arguments field', () => { - expect( - codexStructuredPermissionPolicyForSettings({ - agentDefaultArgs: { - codex: '--profile review --add-dir /repo -c model_reasoning_effort=high' - } - }) - ).toEqual(MANUAL) + // Codex has no edits-only mode, so a stray one is the safer neighbour rather than more access. + it('asks for a mode Codex does not have', () => { + expect(codexStructuredPermissionPolicy('accept-edits')).toEqual(ASK) }) }) diff --git a/src/main/codex/codex-structured-permission-policy.ts b/src/main/codex/codex-structured-permission-policy.ts index 3dbf5a07085c..5b48f75f0d62 100644 --- a/src/main/codex/codex-structured-permission-policy.ts +++ b/src/main/codex/codex-structured-permission-policy.ts @@ -1,49 +1,54 @@ -import type { GlobalSettings } from '../../shared/global-settings-types' -import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import type { CodexApprovalsReviewer } from '../../shared/codex-subagent-reviewer' export type CodexStructuredPermissionPolicy = - | { approvalPolicy: 'never'; sandbox: 'danger-full-access' } - | { approvalPolicy: 'on-request'; sandbox: 'workspace-write' } + | { + approvalPolicy: 'never' + sandbox: 'danger-full-access' + approvalsReviewer: CodexApprovalsReviewer + } + | { + approvalPolicy: 'on-request' + sandbox: 'workspace-write' + approvalsReviewer: CodexApprovalsReviewer + } -/** Yolo: no approval prompts, no sandbox. */ -const BYPASS_POLICY = { approvalPolicy: 'never', sandbox: 'danger-full-access' } as const +/** Full access: no approval prompts, no sandbox. */ +const FULL_ACCESS_POLICY = { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + approvalsReviewer: 'user' +} as const /** - * Manual: approvals on, writes confined to the workspace. + * Ask: approvals on, writes confined to the workspace. * - * Why state it rather than omit it, which is what Manual used to do: app-server resolves an - * omitted field through the `config.toml` it was started with, and Orca mirrors the user's - * `~/.codex/config.toml` into the managed home it hands the app-server. Measured against codex - * 0.153.4 — with `approval_policy = "never"` in that file, a FRESH Manual thread comes up - * `never` + `dangerFullAccess` and never prompts. Manual was not a posture at all; it was - * "inherit whatever the config says". A resume additionally inherits the policy the thread was - * last started with, which is the path the bug was reported on; that half is not isolated here, - * because staging a real Yolo thread needs a live turn before codex writes the rollout. + * Why state it rather than omit it: app-server resolves an omitted field through the `config.toml` + * it was started with, which Orca mirrors from the user's own. Measured against codex 0.153.4 — + * with `approval_policy = "never"` in that file, a fresh thread that omits it comes up `never` + + * `dangerFullAccess` and never prompts, and a resume inherits the policy the thread last ran with. * * Why `workspace-write` and not codex's built-in `read-only`: read-only would override a - * deliberate `sandbox_mode = "workspace-write"` and make every file write in a Manual session - * need an approval it did not need before. This still resets Yolo's `danger-full-access`. + * deliberate `sandbox_mode = "workspace-write"` and make every file write need an approval it did + * not need before. A sandbox mode, never a `sandboxPolicy` object, so Codex still folds in the + * user's `[sandbox_workspace_write]` roots and network access. */ -const MANUAL_POLICY = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const +const ASK_POLICY = { + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + approvalsReviewer: 'user' +} as const + +/** Approve for me: Ask's sandbox and approvals, reviewed by Codex's own reviewer agent. */ +const AUTO_POLICY = { ...ASK_POLICY, approvalsReviewer: 'auto_review' } as const /** - * The Agent Permissions setting as app-server thread policy. - * - * Derived per acquisition from the resolved launch arguments, never from the free-text Arguments - * field: app-server takes a narrower option set than the interactive CLI and the two are versioned - * apart, so the only thing read out of that field is the posture the toggle stores in it. An - * untouched profile resolves to the default Orca ships, which is the bypass flag. - * - * Always a policy, never `undefined`: both postures have to be said out loud, because the one - * that goes unsaid is the one a resume silently inherits from the other. + * A chat's permission mode as app-server thread policy, sent on `thread/start` and `thread/resume`. + * Every field is stated for every mode: one left unsaid is what a resume inherits from the last. + * Codex offers no edits-only mode, so anything else asks. */ -export function codexStructuredPermissionPolicyForSettings( - settings: - | Partial> - | null - | undefined +export function codexStructuredPermissionPolicy( + mode: AgentChatPermissionMode ): CodexStructuredPermissionPolicy { - return resolvedTuiAgentArgsBypassPermissions('codex', settings, process.platform) - ? BYPASS_POLICY - : MANUAL_POLICY + return mode === 'full-access' ? FULL_ACCESS_POLICY : mode === 'auto' ? AUTO_POLICY : ASK_POLICY } diff --git a/src/main/codex/codex-structured-real-cli-visuals.test.ts b/src/main/codex/codex-structured-real-cli-visuals.test.ts index c9b2e9869b7b..1d259b8aa79d 100644 --- a/src/main/codex/codex-structured-real-cli-visuals.test.ts +++ b/src/main/codex/codex-structured-real-cli-visuals.test.ts @@ -9,9 +9,10 @@ import { resolveCodexCommand } from '../codex-cli/command' import { NATIVE_CHAT_VISUALS_SKILL_NAME } from '../native-chat/native-chat-visuals-skill-location' import { openCodexAppServerConnection } from './codex-app-server-connection' import { withCodexVisualsThreadConfig } from './codex-structured-visuals' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1' -const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const +const MANUAL = codexStructuredPermissionPolicy('ask') describe.skipIf(!enabled)('Codex real app-server chat visuals', () => { it('lists the visuals skill and grants the chat folder beside the user writable roots', async () => { diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 21eed4e0c4ce..3fb03fd487da 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -115,7 +115,10 @@ export async function acquireCodexStructuredSession(input: { } acquisitions.assertCurrent(sessionId, attempt) const launch = await deps - .resolveLaunch({ identity: acquireInput.identity }) + .resolveLaunch({ + identity: acquireInput.identity, + ...(acquireInput.permissionMode ? { permissionMode: acquireInput.permissionMode } : {}) + }) .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index f319ff636336..f3cb50c74d01 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -23,6 +23,7 @@ import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker import type { CodexJournalTranslator } from './codex-structured-journal-translation' import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore @@ -31,6 +32,13 @@ import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess +/** What one start asks of the launch: the conversation, and the permission mode the host decided + * it runs, which absent asks. */ +export type CodexStructuredLaunchInput = { + identity: AgentSessionJournalIdentity + permissionMode?: AgentChatPermissionMode +} + export type CodexStructuredLaunch = { command: string args: string[] @@ -88,9 +96,7 @@ export type CodexStructuredSessionEvent = export type CodexStructuredSessionAdapterDeps = { resolveAccountKind?: (home: string) => AgentSessionAccountKind | undefined - resolveLaunch: (input: { - identity: AgentSessionJournalIdentity - }) => Promise + resolveLaunch: (input: CodexStructuredLaunchInput) => Promise onEvent?: (event: CodexStructuredSessionEvent) => void /** Where bookkeeping a close or exit does after the child is gone reports a failure. */ logger?: StructuredAgentSessionLogger diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index efd32b666cd5..6920184e2290 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -5,7 +5,7 @@ import { openCodexAppServerConnection, type CodexAppServerConnection } from './codex-app-server-connection' -import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { openCodexThread } from './codex-structured-thread-open' function connectionFor( @@ -20,10 +20,7 @@ describe('openCodexThread', () => { thread: { id: method === 'thread/start' ? 'thread-created' : 'thread-existing' } })) const connection = connectionFor(request) - const permissionPolicy = { - approvalPolicy: 'never' as const, - sandbox: 'danger-full-access' as const - } + const permissionPolicy = codexStructuredPermissionPolicy('full-access') await openCodexThread( connection, @@ -39,7 +36,12 @@ describe('openCodexThread', () => { expect(request).toHaveBeenNthCalledWith( 1, 'thread/start', - { cwd: '/workspace', approvalPolicy: 'never', sandbox: 'danger-full-access' }, + { + cwd: '/workspace', + approvalPolicy: 'never', + sandbox: 'danger-full-access', + approvalsReviewer: 'user' + }, { timeoutMs: 2_000 } ) expect(request).toHaveBeenNthCalledWith( @@ -50,23 +52,21 @@ describe('openCodexThread', () => { cwd: '/workspace', approvalPolicy: 'never', sandbox: 'danger-full-access', + approvalsReviewer: 'user', excludeTurns: true }, { timeoutMs: 2_000 } ) }) - // The regression this pins: Manual used to resolve to no policy at all, and the params below - // spread it — so neither field was sent and app-server fell back to the config.toml of the - // home Orca mirrors from the user's ~/.codex. With `approval_policy = "never"` there, a Manual - // session never prompted; a resume separately inherits the policy it was last started with. - it('sends Manual as an explicit policy on resume, not as absent fields', async () => { + // The regression this pins: an omitted policy falls back to the config.toml of the home Orca + // mirrors from the user's ~/.codex, and a resume inherits the policy it last ran with. With + // `approval_policy = "never"` there, an Ask chat would never prompt. + it('sends Ask as an explicit policy on resume, not as absent fields', async () => { const request = vi.fn(async (_method: string, _params?: Record) => ({ thread: { id: 'thread-existing' } })) - const permissionPolicy = codexStructuredPermissionPolicyForSettings({ - agentDefaultArgs: { codex: '' } - }) + const permissionPolicy = codexStructuredPermissionPolicy('ask') await openCodexThread( connectionFor(request), @@ -75,9 +75,16 @@ describe('openCodexThread', () => { ) const params = request.mock.calls[0]?.[1] ?? {} - expect(params).toMatchObject({ approvalPolicy: 'on-request', sandbox: 'workspace-write' }) - // Absence is the bug, so assert the keys are carried, not merely that they are not Yolo. - expect(Object.keys(params)).toEqual(expect.arrayContaining(['approvalPolicy', 'sandbox'])) + expect(params).toMatchObject({ + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + approvalsReviewer: 'user' + }) + // Absence is the bug, so assert the keys are carried, not merely that they are not Full access. + expect(Object.keys(params)).toEqual( + expect.arrayContaining(['approvalPolicy', 'sandbox', 'approvalsReviewer']) + ) + expect(params).not.toHaveProperty('sandboxPolicy') }) it('preserves an explicitly reported service tier, including Standard', async () => { diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 6db2a74a9f6d..52a653a68e3c 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -34,15 +34,9 @@ import { // the turn to open and steers it. /** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise - * become an arbitrary client-controlled `turn/start` parameter. Permission posture is owned by - * Agent Permissions and applied when the thread opens. */ -const CODEX_TURN_OPTION_KEYS = new Set([ - 'model', - 'effort', - 'approvalsReviewer', - 'personality', - 'serviceTier' -]) + * become an arbitrary client-controlled `turn/start` parameter. Permission posture is the chat's + * permission mode, stated when the thread opens: a saved reviewer here would override it. */ +const CODEX_TURN_OPTION_KEYS = new Set(['model', 'effort', 'personality', 'serviceTier']) export function isCodexTurnOptionKey(key: string): boolean { return CODEX_TURN_OPTION_KEYS.has(key) diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts index 93430c3d6af7..bcc2d83ccb1a 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -10,13 +10,14 @@ import { CODEX_VISUALS_SETUP_BUDGET_MS, prepareCodexThreadForVisuals } from './codex-structured-visuals' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' const VISUALS: NativeChatVisualsLaunch = { folder: '/state/native-chat-visuals/abc', skill: { pluginDir: '/app/native-chat-visuals', skillsRoot: '/app/native-chat-visuals/skills' } } -const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const -const YOLO = { approvalPolicy: 'never', sandbox: 'danger-full-access' } as const +const MANUAL = codexStructuredPermissionPolicy('ask') +const YOLO = codexStructuredPermissionPolicy('full-access') type Call = { method: string; params?: Record; timeoutMs?: number } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts index faa99e49651f..262ab8e4e2ff 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts @@ -71,6 +71,9 @@ function parseModel(value: unknown): AgentSessionModelOption | null { ...(typeof row.supportsFastMode === 'boolean' ? { supportsFastMode: row.supportsFastMode } : {}), + ...(typeof row.supportsAutoMode === 'boolean' + ? { supportsAutoMode: row.supportsAutoMode } + : {}), ...(serviceTiers ? { serviceTiers } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index 5a1e43ce2223..9c6eab3097f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -7,6 +7,7 @@ import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' +import type { AgentChatPermissionModeSupport } from '../../../shared/agent-chat-permission-mode' /** `agent` names the Orca agent whose sessions this describes; the storage fields bound its records. */ export type StructuredAgentDefinition = AgentSessionStoredAgent & { @@ -20,6 +21,8 @@ export type StructuredAgentDefinition = AgentSessionStoredAgent & { /** An unpicked effort reads as the model's default effort, as a running child reports it. */ effortDefaultsToModel: boolean } + /** The permission modes its chats offer, each a launch policy; absent keeps Agent Permissions. */ + permissionModes?: AgentChatPermissionModeSupport } /** An agent whose account is one config directory, as Claude's and Codex's are. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index db83f5c2e7de..a8abf52f02bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -68,11 +68,13 @@ export async function acquireOwner( } catch (error) { throw new AgentSessionPreSpawnError(error) } + const permissionMode = await input.launchPermissionMode?.(record) const attempt = mintStructuredAgentSessionStartupAttempt({ record, identity: journalIdentityFor(record, input.params), // Retries must recover the original reservation, not mint a second child. spawnToken, + ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), optionRevision: input.optionRevision diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c3271ff3d477..e4de3016b67c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -17,6 +17,7 @@ import type { } from '../../../shared/agent-session-wire' import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import { admitAttachOrRefuse, attachJournal, @@ -75,6 +76,10 @@ export type AttachFlowInput = { onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void /** The conversation's option revision, which the child's reports are stamped with. */ optionRevision: () => number + /** The permission mode a start of this record launches with; absent or undefined for none. */ + launchPermissionMode?: ( + record: AgentSessionRecord + ) => Promise /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is * true only when this attach spawned the provider child, so a re-attach to a live one is not * mistaken for a cold acquire. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 5ababd586e6a..edae7213d092 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -32,6 +32,7 @@ import { } from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' @@ -205,6 +206,8 @@ async function runAttachUnderAbort( return startupAttempts.track(sessionId, startup) }, optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), + launchPermissionMode: async (record) => + (await structuredAgentSessionLaunchPermission(context.deps, record))?.mode, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { @@ -222,6 +225,10 @@ async function runAttachUnderAbort( const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null const startedFor = acquiredOwner ? options.startedFor : current?.startedFor + // A re-attach to a live child keeps the mode that child was launched with. + const permissionMode = acquiredOwner + ? attempt.startup?.permissionMode + : current?.permissionMode // A re-attach to a live child keeps the sink that child already writes through. const eventSink = acquiredOwner ? attemptSink @@ -248,7 +255,8 @@ async function runAttachUnderAbort( fence, // A re-attach to a live child keeps what that child already proved, and its cause. phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready'), - ...(startedFor === undefined ? {} : { startedFor }) + ...(startedFor === undefined ? {} : { startedFor }), + ...(permissionMode === undefined ? {} : { permissionMode }) } } await recoverStructuredRewind( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 9ff8543d514e..9791d4afda22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -22,7 +22,8 @@ import type { import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { recoverStructuredRewind } from './structured-rewind-recovery' -import { retireSignedOutStructuredAgentSessionChild } from './structured-agent-session-signed-out-child' +import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' +import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -57,8 +58,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise - /** Stops a child that reported it is not signed in, so the start after it reads a new login. */ - stopSignedOutAgent: (sessionId: string) => Promise + /** Stops a child the chat outgrew, so the start after it reads a new login or permission mode. */ + stopOutgrownAgent: (sessionId: string) => Promise clientDelivery: Pick< StructuredAgentSessionClientDelivery, 'publishRestored' | 'readChildWork' | 'readStopping' | 'publishStatus' @@ -76,7 +77,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { const session = input.clientDelivery.readStopping(sessionId) ? undefined : sessions.get(sessionId) - await retireSignedOutStructuredAgentSessionChild(sessionId, session, { + await retireOutgrownStructuredAgentSessionChild(sessionId, session, { work: { childWork: () => input.clientDelivery.readChildWork(sessionId), hasOpenDispatch: () => { @@ -86,7 +87,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true }, startUnavailable: () => deps.adapter.startUnavailable?.(sessionId), - stopAgent: input.stopSignedOutAgent, + launchPermission: async () => { + const record = deps.store.getRecord(sessionId) + return record ? structuredAgentSessionLaunchPermission(deps, record) : null + }, + stopAgent: input.stopOutgrownAgent, logger: deps.logger }) return input.ensureProviderChild(sessionId, startedFor) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index cb9cd10fea68..d40422410d2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -30,6 +30,7 @@ import { sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import { cancelPlan, promptPlan, @@ -174,10 +175,14 @@ export async function setStructuredAgentSessionOption( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { - // Outside the queue: a pick made while the provider starts then queues behind what its start persists. - await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) + // The permission mode is only ever the record's: the next start launches with it. + const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + if (!recordOnly) { + // Outside the queue: a pick made while the provider starts then queues behind what its start persists. + await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) + } const plan = setOptionPlan(params) - const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child + const atRest = () => recordOnly || !context.sessions.get(params.envelope.sessionId)?.child return mutateStructuredAgentSession( context, caller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index d6372878b2bd..638f153a3bd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -26,6 +26,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract' +import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' export type StructuredAgentSessionCaller = { callerKey: string } @@ -73,6 +74,9 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** The queued message whose delivery started this child, fixed when the start is made; absent * for any other start. In memory only: it tells a restart offer its own start from another. */ readonly startedFor?: string + /** The permission mode this child was launched with; absent for an agent with none. In memory + * only: a send after the chat's mode changes starts a new child. */ + readonly permissionMode?: AgentChatPermissionMode close?: StructuredAgentSessionChildClose } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 681dd3088a82..4bd63df67b6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -124,7 +124,7 @@ export class StructuredAgentSessionHost { ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. - stopSignedOutAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), + stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), clientDelivery: this.clientDelivery }) this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { 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 new file mode 100644 index 000000000000..fbac47d519dd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -0,0 +1,113 @@ +// The mode a start launches with: Claude's Approve for me needs its model listed as supporting it, +// read from the saved catalog only, and an unknown answer is never treated as a change. + +import { describe, expect, it, vi } 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, options: Record | undefined): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent and options. + return { sessionId: 'session-1', provider, ...(options ? { options } : {}) } 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 = { + read, + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => undefined), + stop: vi.fn(), + providerStarted: vi.fn() + } + return { + read, + result: structuredAgentSessionLaunchPermission( + { agents: claudeAndCodexDeclared(), modelCatalog }, + record(provider, options) + ) + } +} + +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.toEqual({ mode: 'accept-edits', known: true }) + expect(claude.read).not.toHaveBeenCalled() + const codex = launchPermission('codex', { permissionMode: 'auto' }) + await expect(codex.result).resolves.toEqual({ mode: 'auto', known: true }) + expect(codex.read).not.toHaveBeenCalled() + }) + + it('asks for a chat saved before modes existed', async () => { + await expect(launchPermission('claude', undefined).result).resolves.toEqual({ + mode: 'ask', + known: true + }) + }) + + 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 }]) + ) + await expect(supported.result).resolves.toEqual({ mode: 'auto', known: true }) + expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true })) + }) + + it('matches a chat with no model to the listed default', async () => { + await expect( + launchPermission( + 'claude', + { permissionMode: 'auto' }, + listing([ + { id: 'sonnet', isDefault: true, supportsAutoMode: false }, + { id: 'opus', supportsAutoMode: true } + ]) + ).result + ).resolves.toEqual({ mode: 'ask', known: true }) + }) + + it('asks, as an unknown answer, when the catalog cannot say', 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.toEqual({ mode: 'ask', known: false }) + } + }) + + it('has none for an agent with no modes', async () => { + await expect(launchPermission('grok', { permissionMode: 'full-access' }).result).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 new file mode 100644 index 000000000000..a911f942725c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -0,0 +1,56 @@ +// The permission mode a chat's agent starts with: its saved choice, narrowed by what the host's +// model catalog lists for the chat's model. A start launches with it and the child keeps it. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + chatPermissionModeNeedsModelSupport, + effectiveChatPermissionMode, + type AgentChatPermissionMode +} from '../../../shared/agent-chat-permission-mode' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' + +export type StructuredAgentSessionLaunchPermission = { + mode: AgentChatPermissionMode + /** False when the mode rests on a model the saved catalog does not describe. */ + known: boolean +} + +/** Saved catalog only: a start never waits on a listing. Matches the model by id, or the listed + * default when the chat names none. */ +async function listedAutoModeSupport( + catalog: StructuredAgentSessionHostDeps['modelCatalog'], + record: AgentSessionRecord +): Promise { + const listing = await catalog + ?.read({ agent: record.provider, sessionId: record.sessionId, savedOnly: true }) + .catch(() => null) + if (!listing || listing.origin === 'unknown') { + return undefined + } + const model = record.options?.model + const row = listing.models.find((entry) => + model === undefined || model === 'default' ? entry.isDefault : entry.id === model + ) + return row?.supportsAutoMode +} + +/** Null for an agent whose chats offer no modes. */ +export async function structuredAgentSessionLaunchPermission( + deps: Pick, + record: AgentSessionRecord +): Promise { + const support = deps.agents.definition(record.provider)?.permissionModes + if (!support) { + return null + } + const intent = record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + if (!chatPermissionModeNeedsModelSupport(support, intent)) { + return { mode: effectiveChatPermissionMode(support, intent, false), known: true } + } + const supported = await listedAutoModeSupport(deps.modelCatalog, record) + return { + mode: effectiveChatPermissionMode(support, intent, supported === true), + known: supported !== undefined + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 49f321b379fd..6af3a5f69897 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -60,9 +60,11 @@ describe('options at rest', () => { expect(result.models).toEqual([]) expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } + // The resting conversation runs Codex, whose chats state a permission mode. expect((await readStructuredAgentSessionOptions(context, SESSION)).current).toEqual({ model: 'later-model', - effort: 'off' + effort: 'off', + permissionMode: 'ask' }) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 1e6d336883a1..f83cfd4ece8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -12,6 +12,10 @@ import { } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + savedChatPermissionMode +} from '../../../shared/agent-chat-permission-mode' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' @@ -24,14 +28,27 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick +/** Whether the agent takes this pick: a permission mode it offers, or an option key it names. */ +function acceptsOptionPick( + definition: StructuredAgentDefinition | null, + key: string, + value: string +): boolean { + return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + ? definition?.permissionModes?.modes.some((mode) => mode === value) === true + : definition?.restingOptions.acceptsKey(key) === true +} + /** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ export function structuredAgentSessionOptionOverridesRefusal( agents: Pick, provider: string, options: Readonly> ) { - const rules = agents.definition(provider)?.restingOptions - const key = Object.keys(options).find((key) => !rules?.acceptsKey(key)) + const definition = agents.definition(provider) + const key = Object.keys(options).find( + (key) => !acceptsOptionPick(definition, key, options[key] ?? '') + ) return key === undefined ? null : refuse( @@ -104,7 +121,10 @@ export async function recordStructuredAgentSessionOptionIntent( input: { key: string; value: string } ): Promise> { const record = deps.store.getRecord(ctx.sessionId) - if (!record || !restingOptionRules(deps.agents, record)?.acceptsKey(input.key)) { + if ( + !record || + !acceptsOptionPick(deps.agents.definition(record.provider), input.key, input.value) + ) { return { ok: false, refusal: refuse( @@ -178,8 +198,17 @@ export async function readStructuredAgentSessionOptions( const capabilities = agents.capabilities(agent) const floor = session.journal.context.floor() const contextFloor = floor ? { contextFloor: floor } : {} + // The record's, live or at rest: a child never reports it, and only its next start applies it. + const permissionModes = agents.definition(agent)?.permissionModes + const permissionMode = permissionModes + ? savedChatPermissionMode( + permissionModes, + store.getRecord(sessionId)?.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + ) + : undefined return { ...options, + ...(permissionMode ? { current: { ...options.current, permissionMode } } : {}), rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts similarity index 77% rename from src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index ae09e13d8782..a01b8933b8dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -1,5 +1,6 @@ -// A child that reported it is not signed in is replaced before the next send, so a sign-in made -// since reaches a new child; an agent that read its login once at start would fail again. +// A child the chat outgrew is replaced before the next send: one that reported it is not signed in, +// so a sign-in made since reaches a new child, and one launched with a permission mode the chat no +// longer has, so the new mode reaches a new child. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -14,9 +15,10 @@ import { type RestTestRig } from './structured-agent-session-rest-test-rig' import { - retireSignedOutStructuredAgentSessionChild, + retireOutgrownStructuredAgentSessionChild, structuredAgentSessionChildReportedSignedOut -} from './structured-agent-session-signed-out-child' +} from './structured-agent-session-outgrown-child' +import type { StructuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { AgentJournalItemBody, @@ -185,7 +187,7 @@ describe('structuredAgentSessionChildReportedSignedOut', () => { }) }) -describe('retireSignedOutStructuredAgentSessionChild', () => { +describe('retireOutgrownStructuredAgentSessionChild', () => { const liveChildWork: AgentChildWorkView = { id: 'subagent-1', kind: 'agent', @@ -205,11 +207,13 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { const retire = async ( input: Parameters[0], work: Partial = {}, - stopAgent = vi.fn(async () => undefined) + stopAgent = vi.fn(async () => undefined), + launchPermission?: () => Promise ) => { const warn = vi.fn() - await retireSignedOutStructuredAgentSessionChild('session', conversation(input), { + await retireOutgrownStructuredAgentSessionChild('session', conversation(input), { work: { ...idle, ...work }, + ...(launchPermission ? { launchPermission } : {}), stopAgent, logger: { warn, error: vi.fn() } }) @@ -261,8 +265,60 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { }) ) expect(warn).toHaveBeenCalledWith( - 'replacing a signed-out agent failed', + 'replacing an outgrown agent failed', expect.objectContaining({ sessionId: 'session' }) ) }) + + describe('a child launched with another permission mode', () => { + const now = (mode: StructuredAgentSessionLaunchPermission['mode'], known = true) => + vi.fn(async () => ({ mode, known })) + const askChild = { child: childAt({ permissionMode: 'ask' }) } + + it('is stopped once, and one launched with the current mode is kept', async () => { + expect( + (await retire(askChild, {}, undefined, now('full-access'))).stopAgent + ).toHaveBeenCalledWith('session') + expect((await retire(askChild, {}, undefined, now('ask'))).stopAgent).not.toHaveBeenCalled() + }) + + it('is kept while it owes work, as a signed-out one is', async () => { + for (const kept of [ + await retire({ ...askChild, activeTurnId: 'turn-1' }, {}, undefined, now('auto')), + await retire(askChild, { childWork: () => [liveChildWork] }, undefined, now('auto')), + await retire({ ...askChild, items: [pendingPrompt] }, {}, undefined, now('auto')) + ]) { + expect(kept.stopAgent).not.toHaveBeenCalled() + } + }) + + it('is kept while starting, closing, or when the current mode is unknown', async () => { + const starting = { child: childAt({ permissionMode: 'ask', phase: 'starting' }) } + expect((await retire(starting, {}, undefined, now('auto'))).stopAgent).not.toHaveBeenCalled() + expect( + (await retire(askChild, {}, undefined, now('auto', false))).stopAgent + ).not.toHaveBeenCalled() + expect( + (await retire(askChild, {}, undefined, async () => null)).stopAgent + ).not.toHaveBeenCalled() + }) + + 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() + expect(current).not.toHaveBeenCalled() + }) + }) }) + +const pendingPrompt: Row = { + itemId: 'approval-1', + fence: 2, + body: { + kind: 'approval', + title: 'Run a command', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts similarity index 72% rename from src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 40ea59a69eb8..39257d36a71c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -1,5 +1,6 @@ -// A child that reported its agent is not signed in is replaced before the next send. Some agents -// read their saved login only when they start, so a sign-in made since reaches a new child only. +// A child the chat has outgrown is replaced before the next send: one that reported its agent is not +// signed in, or one launched with a permission mode the chat no longer has. Agents read their saved +// login and their permission mode only when they start, so either change reaches a new child only. import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -13,6 +14,7 @@ import { type StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' /** What the check reads of a conversation; every read skips building a snapshot. */ type SignedOutReading = Pick & { @@ -78,30 +80,48 @@ function owesWakeUp( ) } +/** The running child was launched with another mode than the chat's. Only a known mode counts, so + * a catalog the host cannot read never restarts a child. */ +async function launchedWithOtherPermissionMode( + child: SignedOutReading['child'], + current: (() => Promise) | undefined +): Promise { + if (!child || child.phase === 'starting' || child.close || !child.permissionMode || !current) { + return false + } + const permission = await current() + return permission !== null && permission.known && permission.mode !== child.permissionMode +} + /** For a caller inside the session's serialize, before it hands the next send to the child. Work * the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails leaves * the close begun, which the start joins. */ -export async function retireSignedOutStructuredAgentSessionChild( +export async function retireOutgrownStructuredAgentSessionChild( sessionId: string, session: SignedOutReading | undefined, deps: { work: StructuredAgentSessionChildWorkReads startUnavailable?: () => AgentSessionUnavailable | undefined + /** The mode a start of the chat would launch with now. */ + launchPermission?: () => Promise stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } ): Promise { if ( !session || - !structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) || + !( + structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) || + (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) + ) || structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || owesWakeUp(session.journal, deps.work.childWork()) ) { return } await deps.stopAgent(sessionId).catch((error: unknown) => - deps.logger.warn('replacing a signed-out agent failed', { - scope: 'signed-out-child', + deps.logger.warn('replacing an outgrown agent failed', { + scope: 'outgrown-child', sessionId, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts new file mode 100644 index 000000000000..de9c09b91ba3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts @@ -0,0 +1,105 @@ +// A chat's permission mode is the record's: a pick writes only the record, even with the agent +// running, and the next send after a change goes to an agent started with the new mode. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { hostTestOperationId } from './structured-agent-session-host-test-data' + +describe('a chat permission mode', () => { + let rig: RestTestRig + + beforeEach(async () => { + rig = await createRestTestRig() + }) + + afterEach(async () => { + await rig.dispose() + }) + + const fence = () => rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const pick = (value: string) => { + const fields = { key: 'permissionMode', value } + return rig.host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + } + + it('starts a chat saved before modes existed as Ask, and reads it back as Ask', async () => { + await foundRestTestChat(rig) + expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect.objectContaining({ permissionMode: 'ask' }) + ) + expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('ask') + }) + + it('writes a pick to the record only, with the agent running', async () => { + await foundRestTestChat(rig) + const setOption = vi.spyOn(rig.host.deps.adapter, 'setOption') + + expect(await pick('full-access')).toMatchObject({ + ok: true, + value: { options: { permissionMode: 'full-access' } } + }) + expect(setOption).not.toHaveBeenCalled() + expect(rig.store.getRecord(SESSION)?.options?.permissionMode).toBe('full-access') + expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('full-access') + expect(rig.host.readStatusSummary(SESSION)?.permissionMode).toBe('full-access') + }) + + it('refuses a mode the agent does not offer', async () => { + await foundRestTestChat(rig) + // Codex has no edits-only mode. + expect(await pick('accept-edits')).toMatchObject({ ok: false }) + expect(await pick('bypassPermissions')).toMatchObject({ ok: false }) + expect(rig.store.getRecord(SESSION)?.options?.permissionMode).toBeUndefined() + }) + + it('starts a new agent with the new mode for the next send, and keeps it after', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockClear() + await pick('auto') + + expect((await rig.host.send(CALLER, restTestSend('after the change', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ permissionMode: 'auto' }) + ) + + expect((await rig.host.send(CALLER, restTestSend('and again', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + }) + + it('keeps the running agent for a send while its reply runs', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockClear() + const holds = rig.adapter.holdsDispatch.mockReturnValue(true) + await pick('full-access') + + expect((await rig.host.send(CALLER, restTestSend('mid-reply', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + holds.mockReturnValue(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index 76e4942ffb6d..9eca01f75a1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -120,7 +120,11 @@ describe('a registered definition', () => { const options = await readAtRest(agents) expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) // No pick for effort: the registered rules read the model's default. - expect(options.current).toEqual({ model: 'pilot-model', effort: 'low' }) + expect(options.current).toEqual({ + model: 'pilot-model', + effort: 'low', + permissionMode: 'ask' + }) expect(options.threadGoal).toEqual({ current: null }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index ec239cf3a304..297b5e396cd2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -5,6 +5,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' /** A spawned child with no stdout frame or stderr line for this long has stopped starting. */ export const STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS = 60_000 @@ -28,6 +29,8 @@ export type StructuredAgentSessionStartupAttempt = { readonly launch: Pick /** The saved options the start launches with, as intent; never a catalog guess. */ readonly options?: Readonly> + /** The permission mode the start launches with; absent for an agent with none. */ + readonly permissionMode?: AgentChatPermissionMode /** Provider events may begin before acquisition returns. */ readonly events?: StructuredAgentSessionEventSink /** Background work this start's child reports, scoped to the attempt. Unset until the host routes diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index efecd7f77376..b704ec9eee30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -14,6 +14,7 @@ import { randomUUID } from 'node:crypto' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { sameProviderChild } from './structured-agent-session-provider-child' import { @@ -26,6 +27,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { record: AgentSessionRecord identity: AgentSessionJournalIdentity spawnToken: string + permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal optionRevision: () => number @@ -42,6 +44,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { ...(record.launchDirectory === undefined ? {} : { launchDirectory: record.launchDirectory }) }, ...(record.options ? { options: record.options } : {}), + ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), ...(input.signal ? { signal: input.signal } : {}), optionRevision: input.optionRevision diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index ed9371e8e42c..46a62a05dc49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -31,6 +31,7 @@ export function structuredStatusSummariesEqual( (a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) && a.latestPrompt === b.latestPrompt && a.model === b.model && + a.permissionMode === b.permissionMode && a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 70980484b772..5edc115cdcca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -7,6 +7,10 @@ import type { StructuredAgentSessionStatusState } from './structured-agent-sessi import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory' +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + isAgentChatPermissionMode +} from '../../../shared/agent-chat-permission-mode' export function structuredAgentSessionStatusSummary({ sessionId, @@ -37,6 +41,7 @@ export function structuredAgentSessionStatusSummary({ // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) + const permissionMode = record?.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined return { sessionId, @@ -56,6 +61,7 @@ export function structuredAgentSessionStatusSummary({ ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), + ...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}), ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), diff --git a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts index 9b2d56aa49d4..f6285770c538 100644 --- a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -19,7 +19,6 @@ import { import { nativeChatVisualsFolderFor } from './native-chat-visuals-folder' const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' } -const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const const scratch: string[] = [] afterEach(() => { for (const dir of scratch.splice(0)) { @@ -91,7 +90,6 @@ describe('inline visuals launch preference', () => { resolveWorkspacePath: async () => '/repo', resolveCommand: () => '/bin/codex', resolveLaunchArgs: () => [], - resolvePermissionPolicy: () => MANUAL, resolveEnvironment: async () => ({ [NATIVE_CHAT_VISUALS_DIR_ENV]: '/inherited', KEEP: 'yes' diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index 6d5294bc11d8..d7dafaaf4a83 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -6,6 +6,7 @@ import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-setting import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' +import { applyAgentPermissionMode } from '../../../shared/tui-agent-permissions' // Simulates a profile created before the dedicated Experimental switch was persisted. function normalizeLegacyProfile(overrides: Record): PersistedState['settings'] { @@ -125,3 +126,25 @@ describe('chat appearance settings', () => { ).toBeUndefined() }) }) + +describe('the new-chat permission setting', () => { + it.each([ + ['manual', 'ask'], + ['yolo', 'full-access'] + ] as const)('seeds a profile without it from %s Agent Permissions', (mode, expected) => { + const normalized = normalizeLegacyProfile({ + nativeChatPermissionMode: undefined, + ...applyAgentPermissionMode({ mode }) + }) + expect(normalized.nativeChatPermissionMode).toBe(expected) + }) + + // Seeded once: after the next save the setting is the user's, not a mirror of Agent Permissions. + it('keeps a saved value when Agent Permissions change afterwards', () => { + const normalized = normalizeLegacyProfile({ + nativeChatPermissionMode: 'ask', + ...applyAgentPermissionMode({ mode: 'yolo' }) + }) + expect(normalized.nativeChatPermissionMode).toBe('ask') + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index acf5a485ddd0..f2bb14f81771 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -9,6 +9,7 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat-shell-environment' +import { loadedChatPermissionSetting } from '../../../shared/agent-chat-permission-mode' import { savedChatUiWithTerminalDefaultView, stripRetiredGlobalSettings @@ -133,6 +134,11 @@ export function normalizeLoadedGlobalSettings( nativeChatShellEnvironmentVariables: normalizeNativeChatShellEnvironmentVariables( parsed.settings?.nativeChatShellEnvironmentVariables ), + // Why: the next settings save stores the seed; until then a reload derives the same one. + nativeChatPermissionMode: loadedChatPermissionSetting( + parsed.settings?.nativeChatPermissionMode, + migratedAgentYoloDefaults + ), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, visibleTaskProvidersDefaultedForJira: true, diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 81b3b9c19535..e6866dcaa420 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -82,14 +82,14 @@ describe('a Claude start that Orca fails while the CLI is still running', () => await expect( host.attach( CALLER, - claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) + claude.attachParams(SESSION, null, { options: { permissionMode: 'accept-edits' } }) ) ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') ) - expect(claude.child(SESSION).launch.options.permissionMode).toBe('plan') + expect(claude.child(SESSION).launch.options.permissionMode).toBe('acceptEdits') expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') expect(await failureRows(host)).toEqual([]) }) diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index f4d63c020040..4ebec371630b 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -109,7 +109,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' claude.behave(SESSION, { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() - const saved = { model: 'sonnet', permissionMode: 'plan' } + const saved = { model: 'sonnet', permissionMode: 'accept-edits' } await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved })) ).resolves.toMatchObject({ ok: true }) @@ -119,7 +119,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) expect(claude.child(SESSION).launch.options).toMatchObject({ model: 'sonnet', - permissionMode: 'plan' + permissionMode: 'acceptEdits' }) // The record keeps the saved choices through the start. await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), { @@ -152,8 +152,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' // The CLI answers again; the user sets another option on the running child. behavior.optionWritesHang = false - await setOption(host, 'permissionMode', 'plan') - expect(record(host)?.options).toMatchObject({ model: 'sonnet', permissionMode: 'plan' }) + await setOption(host, 'permissionMode', 'accept-edits') + expect(record(host)?.options).toMatchObject({ model: 'sonnet', permissionMode: 'accept-edits' }) }) it('launches the saved model again after a turn reports another model, another option changes, and the chat is cleared', async () => { @@ -183,8 +183,13 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }) behavior.optionWritesHang = false - await setOption(host, 'permissionMode', 'plan') - expect(record(host)?.options).toEqual({ model: 'sonnet', permissionMode: 'plan' }) + // The permission mode is the record's alone; the running child is not asked. + await setOption(host, 'permissionMode', 'accept-edits') + expect(record(host)?.options).toEqual({ + model: 'sonnet', + effort: 'high', + permissionMode: 'accept-edits' + }) const cleared = await host.conversationCommand(CALLER, { command: 'clear', @@ -217,14 +222,14 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(claude.child(SESSION).launch.options).toMatchObject({ sessionId: freshProviderSessionId, model: 'sonnet', - permissionMode: 'plan' + permissionMode: 'acceptEdits' }) expect(claude.child(SESSION).launch.options.resume).toBeUndefined() await vi.waitFor(() => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high', - permissionMode: 'plan' + permissionMode: 'accept-edits' }) ) }) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c7e3c1d79a12..bdf615647b4c 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -12,7 +12,7 @@ import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' -import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' +import { structuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' @@ -97,10 +97,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR structuredAgentSessionLaunchSeedOptions( agent: StructuredAgentId ): Record | undefined { - return resolveStructuredLaunchSeedOptions( - this.requireStore().getSettings().nativeChatSessionOptions, - agent - ) + return structuredLaunchSeedOptions(this.requireStore().getSettings(), agent) } protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 8002bac661d6..02629e4a5943 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -43,8 +43,6 @@ import { } from '../../shared/tui-agent-launch-defaults' import { isTuiAgent } from '../../shared/tui-agent-config' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' -import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' -import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args' @@ -227,12 +225,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis nativeChatShellEnvironmentPolicy(this.requireStore().getSettings()), resolveClaudeAuthPolicy: () => claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()), - // Re-read per acquisition, like the auth policy above it: the Agent Permissions setting is - // the one copy of this fact, even when Arguments contain permission flags. - resolveClaudePermissionMode: () => - claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()), - resolveCodexPermissionPolicy: () => - codexStructuredPermissionPolicyForSettings(this.requireStore().getSettings()), resolveAgentFullAccess: (agent) => isTuiAgent(agent) && resolvedTuiAgentArgsBypassPermissions( diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index c6e75cd5a230..e7803bb7870c 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -148,7 +148,12 @@ describe('structured agent-session create intent', () => { variable: 'CODEX_HOME', path: '/accounts/selected/home' }) - expect(intent.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'true' }) + expect(intent.options).toEqual({ + model: 'gpt-5.6-sol', + effort: 'medium', + fastMode: 'true', + permissionMode: 'ask' + }) }) it('resolves the record-less catalog account home read-only, never through launch preparation', async () => { @@ -232,7 +237,12 @@ describe('structured agent-session create intent', () => { variable: 'CLAUDE_CONFIG_DIR', path: '/configured/claude-home' }) - expect(intent.options).toEqual({ model: 'opus', effort: 'high', fastMode: 'true' }) + expect(intent.options).toEqual({ + model: 'opus', + effort: 'high', + fastMode: 'true', + permissionMode: 'ask' + }) // createSupport reports this same seed, so a paired client's picker shows what create runs. expect(runtime.structuredAgentSessionLaunchSeedOptions('claude')).toEqual(intent.options) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts index 82307145453c..0a1058c9e820 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts @@ -208,6 +208,32 @@ it('merges held option overrides with host seeds before the first acquisition', ) }) +it('keeps the seeded permission mode beside explicit picks on an acquired create', async () => { + // The fixture's Codex intent, seeded with a permission mode. + const resolveStructuredAgentSessionCreateIntent = async (input: { envelope: unknown }) => ({ + envelope: input.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + options: { model: 'gpt-5.6-sol', effort: 'medium', permissionMode: 'full-access' }, + runtimeKind: 'native' + }) + expect( + await call('agentSession.create', params({ model: 'held-model' }), legacy, { + resolveStructuredAgentSessionCreateIntent + }) + ).toMatchObject({ ok: true, result: { ok: true } }) + expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect.objectContaining({ options: { model: 'held-model', permissionMode: 'full-access' } }) + ) +}) + it('rejects an explicit option the resting agent would reject before founding', async () => { expect( await call('agentSession.create', params({ unknownOption: 'yes' }), capable) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 924450fa19cb..8fd8c2245192 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -1,5 +1,6 @@ // Resolve the host launch identity, then commit an at-rest create or the compatible acquired path. +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { refuse, AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { @@ -129,8 +130,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { attachParams: { ...resolvedAttach, // The ledger covers explicit overrides, never defaults that settings can change on replay. + // Explicit picks replace the saved selection; the permission default is the chat's own. ...(args.options - ? { options: atRest ? { ...resolved.options, ...args.options } : args.options } + ? { + options: atRest + ? { ...resolved.options, ...args.options } + : { ...seededPermissionMode(resolved.options), ...args.options } + } : {}), ...(args.tabId ? { surfaceTabId: args.tabId } : {}), provider: resolved.provider, @@ -238,3 +244,10 @@ export async function createStructuredAgentSessionForWorktree(args: { activate: args.activate }) } + +function seededPermissionMode( + options: Readonly> | undefined +): Record { + const mode = options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + return mode ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : {} +} diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 5ca04d127e38..c074b5daa2da 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -52,6 +52,7 @@ export type RuntimeClientSettings = Pick< | 'worktreeVisibilityDefaults' | 'agentSkillSharingEnabled' | 'machineName' + | 'nativeChatPermissionMode' > & { /** Older clients still read these; neither is persisted or accepted as an update. */ openAgentTabsInChatByDefault?: boolean @@ -90,6 +91,7 @@ export type RuntimeClientSettingsUpdate = Pick< | 'prBotAuthorOverrides' | 'worktreeVisibilityDefaults' | 'machineName' + | 'nativeChatPermissionMode' > export class RuntimeClientSettingsController { @@ -140,6 +142,10 @@ export class RuntimeClientSettingsController { worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings), machineName: settings.machineName ?? '', + // Why projected: a web client's Settings → Chat edits this host's new-chat default. + ...(settings.nativeChatPermissionMode + ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } + : {}), // Why projected: a paired client's AI buttons start these actions' agents, and must honour // the agent saved for each one as the desktop does. Absent on older hosts. sourceControlAi: projectSourceControlLaunchRecipes(settings), diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 27ea24c367ca..705e6a796d58 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -129,6 +129,7 @@ export type RuntimeStore = { hostSettingOverrides?: GlobalSettings['hostSettingOverrides'] agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions'] + nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode'] nativeChatInlineVisuals?: GlobalSettings['nativeChatInlineVisuals'] nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment'] nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables'] diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index ef919351db4f..84e883f63743 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -147,9 +147,6 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveCodexEnvironment, resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), - ...(deps.resolveCodexPermissionPolicy - ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } - : {}), ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}), ...nativeChatVisualsFor(deps) }), @@ -184,9 +181,6 @@ function createClaudeAdapter( resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv, resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs('claude'), resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudePermissionMode - ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } - : {}), attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: context.deliverLifecycle, logger: deps.logger, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 7566249677b6..21b00d319713 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -12,7 +12,6 @@ import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { PiRpcSessionDeps } from '../pi/rpc-session' -import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' @@ -23,7 +22,6 @@ import { type InstalledRuntime } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' -import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' @@ -123,11 +121,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveClaudeLaunchEnv?: () => Promise> | Record /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy - /** The user's Agent Permissions setting for Claude; absent means prompting. */ - resolveClaudePermissionMode?: () => Promise | PermissionMode - /** The same setting for Codex, as app-server thread policy. */ - resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy - /** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */ + /** The Agent Permissions setting for a protocol-driven (ACP) agent: whether it runs with full + * access. Claude and Codex chats run their own permission mode instead. */ resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ resolveAgentLaunchEnv?: (agent: string) => Record diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 865b9e262e3b..71040682bc12 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,4 +1,3 @@ -import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { resolveClaudeCommand } from '../codex-cli/command' @@ -37,8 +36,6 @@ export type StructuredClaudeRuntimeAdapterDeps = { /** Managed-account auth state for a Claude launch, mirroring the terminal preflight. * Required: an absent policy is what silently under-strips. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy - /** The user's Agent Permissions setting for Claude; absent means prompting. */ - resolveClaudePermissionMode?: () => Promise | PermissionMode /** Where the host stores chat attachments; granted to the agent as a readable directory. */ attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] @@ -115,9 +112,6 @@ export function createStructuredClaudeRuntimeAdapter( ? { resolveInheritedEnv: deps.resolveClaudeInheritedEnv } : {}), resolveAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudePermissionMode - ? { resolvePermissionMode: deps.resolveClaudePermissionMode } - : {}), ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}), ...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {}), ...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 62315c377ece..b17acb550c14 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -406,6 +406,7 @@ const NativeChatComposerPane = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 7344937fec24..23bcd942689b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -7,6 +7,7 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' +import { NativeChatComposerPermissionPicker } from './NativeChatPermissionModePicker' import { NativeChatComposerGoalChip } from './NativeChatComposerGoalChip' import { NativeChatContextUsageRing } from './NativeChatContextUsageRing' import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary' @@ -44,6 +45,8 @@ export type NativeChatComposerActionsProps = { onExitGoalMode?: () => void /** Absent until the session has reported or the transcript can estimate. */ contextUsage?: NativeChatContextUsageSummary | null + /** The chat's agent, whose permission modes the picker describes. */ + agent?: string } export function NativeChatComposerActions({ @@ -69,7 +72,8 @@ export function NativeChatComposerActions({ sessionOptionsPickerRequest, onExitGoalMode, contextUsage, - focusComposer + focusComposer, + agent }: NativeChatComposerActionsProps): React.JSX.Element { const stops = primaryAction === 'stop' const resumes = primaryAction === 'resume' @@ -141,6 +145,11 @@ export function NativeChatComposerActions({ {translate('components.native-chat.composer.attach', 'Attach file')} + {onExitGoalMode ? : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index d895611466f8..0f4fe56334bd 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -86,6 +86,7 @@ export type NativeChatComposerFieldProps = { contextUsage?: NativeChatContextUsageSummary | null sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null goalMode?: NativeChatComposerGoalMode + agent?: string } export type NativeChatComposerImageAttachment = { @@ -170,7 +171,8 @@ export function NativeChatComposerField({ sessionOptionsSnapshot, contextUsage, sessionOptionsPickerRequest, - goalMode + goalMode, + agent }: NativeChatComposerFieldProps): React.JSX.Element { const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey) const optionCount = @@ -382,6 +384,7 @@ export function NativeChatComposerField({ sessionOptionsPickerRequest={sessionOptionsPickerRequest} focusComposer={focusComposer} onExitGoalMode={goalMode?.active ? goalMode.exit : undefined} + {...(agent ? { agent } : {})} />
diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx new file mode 100644 index 000000000000..f5bdf2a482a0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx @@ -0,0 +1,251 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)), + fallback + ) +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React = await import('react') + return { + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ + children, + disabled + }: { + children: React.ReactNode + disabled?: boolean + }) => ( +
+ {children} +
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuRadioGroup: ({ + children, + value, + onValueChange, + 'aria-label': ariaLabel + }: { + children: React.ReactNode + value?: string + onValueChange?: (value: string) => void + 'aria-label'?: string + }) => ( +
+ {React.Children.map(children, (child) => { + if ( + !React.isValidElement<{ + value?: string + disabled?: boolean + children?: React.ReactNode + onSelect?: () => void + }>(child) + ) { + return child + } + const props = child.props + return ( + + ) + })} +
+ ), + DropdownMenuRadioItem: ({ children }: { children: React.ReactNode; value: string }) => ( + {children} + ) + } +}) + +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { + NativeChatComposerPermissionPicker, + NativeChatPermissionModePicker, + type NativeChatPermissionModePickerProps +} from './NativeChatPermissionModePicker' + +function props( + overrides: Partial = {} +): NativeChatPermissionModePickerProps { + return { + agent: 'claude', + current: 'ask', + modes: ['ask', 'accept-edits', 'auto', 'full-access'], + onSelect: vi.fn(), + ...overrides + } +} + +function option(mode: AgentChatPermissionMode): HTMLElement { + return screen.getByRole('radio', { + name: (_name, element) => element.getAttribute('data-value') === mode + }) +} + +afterEach(cleanup) + +describe('NativeChatPermissionModePicker', () => { + it('lists each mode the agent offers, in order, with what it does', () => { + render() + + const menu = screen.getByTestId('permission-menu') + expect( + within(menu) + .getAllByRole('radio') + .map((row) => row.getAttribute('data-value')) + ).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) + expect(within(menu).getByText('Ask for approval')).toBeTruthy() + expect( + within(menu).getByText("Asks before edits and commands your settings don't allow") + ).toBeTruthy() + expect( + within(menu).getByText('Approves file edits and file commands; asks for the rest') + ).toBeTruthy() + expect(within(menu).getByText('Reviews approval requests for you')).toBeTruthy() + expect( + within(menu).getByText('Skips approval prompts; your Claude rules and sandbox still apply') + ).toBeTruthy() + }) + + it("describes Codex's modes as Codex runs them", () => { + render( + + ) + expect(screen.queryByText('Accept edits')).toBeNull() + expect(option('ask').textContent).toContain( + 'Works inside the workspace sandbox; asks before going beyond it' + ) + expect(option('full-access').textContent).toContain('Never asks; no sandbox') + }) + + it('describes both agents in the new-chat setting', () => { + render() + expect(option('ask').textContent).toContain( + 'Claude asks unless your settings allow it; Codex asks beyond the workspace sandbox' + ) + expect(option('full-access').textContent).toContain( + 'Skips approval prompts; Codex also runs without its sandbox' + ) + }) + + it('names the current mode on the pill, by category for assistive tech', () => { + render() + const trigger = screen.getByRole('button', { name: 'Permissions Approve for me' }) + expect(trigger.textContent).toContain('Approve for me') + expect(option('auto').getAttribute('aria-checked')).toBe('true') + }) + + it('shows Full access in the warning colour on the pill and in the menu', () => { + render() + + const trigger = screen.getByRole('button', { name: 'Permissions Full access' }) + expect(trigger.querySelector('.text-status-warning')?.textContent).toBe('Full access') + expect(option('full-access').querySelector('.text-status-warning')).not.toBeNull() + expect(option('ask').querySelector('.text-status-warning')).toBeNull() + }) + + it('locks the pill and its rows while disabled', () => { + render() + expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true') + expect(option('full-access')).toHaveProperty('disabled', true) + }) +}) + +describe('NativeChatComposerPermissionPicker', () => { + const descriptor = ( + overrides: Partial = {} + ): SessionOptionDescriptor => ({ + id: 'permissionMode', + label: 'Permissions', + category: 'mode', + kind: { + type: 'select', + currentValue: 'ask', + choices: [ + { value: 'ask', label: 'Ask for approval' }, + { value: 'auto', label: 'Approve for me' }, + { value: 'full-access', label: 'Full access' } + ] + }, + valueSource: 'applied', + transport: 'agent-session', + settable: true, + ...overrides + }) + const surface = () => ({ + getSnapshot: () => [], + setOption: vi.fn(async () => ({ snapshot: [] })), + invokeAction: vi.fn(async () => ({ snapshot: [] })), + subscribe: () => () => {} + }) + + // A host that states no mode, or an agent with none, gets no pill. + it('renders nothing without a permission descriptor', () => { + const { container } = render( + + ) + expect(container.innerHTML).toBe('') + }) + + it('writes a new pick through the option surface, and skips the current one', () => { + const options = surface() + render( + + ) + fireEvent.click(option('ask')) + expect(options.setOption).not.toHaveBeenCalled() + fireEvent.click(option('full-access')) + expect(options.setOption).toHaveBeenCalledWith('permissionMode', 'full-access') + }) + + it('locks only while the descriptor cannot take a pick', () => { + render( + + ) + expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx new file mode 100644 index 000000000000..6ea85a725964 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx @@ -0,0 +1,157 @@ +import { memo, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + isAgentChatPermissionMode, + type AgentChatPermissionMode +} from '../../../../shared/agent-chat-permission-mode' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { + NATIVE_CHAT_PERMISSION_MODE_ICONS, + nativeChatPermissionModeDescription, + nativeChatPermissionModeLabel, + nativeChatPermissionPickerTitle +} from './native-chat-permission-mode-labels' +import { runSessionOptionSurfaceCall } from './native-chat-session-option-surface-call' + +/** Icon and name; Full access reads in the warning colour wherever it is shown. */ +function ModeName(props: { mode: AgentChatPermissionMode; truncate?: boolean }): React.JSX.Element { + const Icon = NATIVE_CHAT_PERMISSION_MODE_ICONS[props.mode] + const label = nativeChatPermissionModeLabel(props.mode) + const body = ( + <> + + {props.truncate ? {label} : {label}} + + ) + return props.mode === 'full-access' ? ( + {body} + ) : ( + {body} + ) +} + +export type NativeChatPermissionModePickerProps = { + current: AgentChatPermissionMode + modes: readonly AgentChatPermissionMode[] + /** Whose descriptions to show; absent describes every agent's. */ + agent?: string | null + disabled?: boolean + onSelect: (mode: AgentChatPermissionMode) => void +} + +function NativeChatPermissionModePickerInner({ + current, + modes, + agent, + disabled = false, + onSelect +}: NativeChatPermissionModePickerProps): React.JSX.Element { + const title = nativeChatPermissionPickerTitle() + // `shrink` overrides the button's shrink-0 so a narrow composer truncates the mode name. + return ( + + + + + + + + + {title} + + + + + {modes.map((mode) => ( + onSelect(mode)} + > +
+ +
+ {nativeChatPermissionModeDescription(mode, agent)} +
+
+
+ ))} +
+
+
+ ) +} + +export const NativeChatPermissionModePicker = memo(NativeChatPermissionModePickerInner) + +/** The composer's permission pill, a view of the snapshot's permission descriptor: absent where the + * host states no mode. Never locked while the agent works; a change applies from the next send. */ +export function NativeChatComposerPermissionPicker({ + surface, + snapshot, + agent +}: { + surface: SessionOptionsSurface | null + snapshot: readonly SessionOptionDescriptor[] + agent?: string +}): React.JSX.Element | null { + const [pending, setPending] = useState(false) + const descriptor = snapshot.find((entry) => entry.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) + const current = descriptor?.kind.type === 'select' ? descriptor.kind.currentValue : undefined + if ( + !surface || + !descriptor || + descriptor.kind.type !== 'select' || + !isAgentChatPermissionMode(current) + ) { + return null + } + const modes = descriptor.kind.choices + .map((choice) => choice.value) + .filter(isAgentChatPermissionMode) + return ( + { + if (mode !== current) { + runSessionOptionSurfaceCall( + mode, + (id) => setPending(id !== null), + () => surface.setOption(AGENT_CHAT_PERMISSION_MODE_OPTION_ID, mode) + ) + } + }} + /> + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index c9b86e97cd16..18d99fc98b92 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -1,6 +1,5 @@ import { memo, useState } from 'react' import { ChevronDown } from 'lucide-react' -import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { SwitchIndicator } from '@/components/ui/switch' import { @@ -32,9 +31,7 @@ import { nativeChatSessionOptionLabel } from './native-chat-session-option-labels' import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' -import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure' -import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' -import { agentSessionWriteFailureText } from './agent-session-write-notice-text' +import { runSessionOptionSurfaceCall } from './native-chat-session-option-surface-call' import { NativeChatModelCombobox } from './NativeChatModelCombobox' import { useNativeChatPickerFocusReturn } from './native-chat-picker-focus-return' import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody' @@ -199,31 +196,6 @@ function DescriptorMenuRows(props: { ) } -/** A host's error is words for its log, so it gets the table's; a local surface's error is - * already written for the person. */ -function optionUpdateFailureDescription(error: unknown): string { - if (error instanceof RuntimeRpcCallError) { - return agentSessionWriteFailureText(agentSessionThrownFailure(error, error.code), 'option') - } - return error instanceof Error ? error.message : String(error) -} - -function runSurfaceCall( - pendingKey: string, - setPendingId: (id: string | null) => void, - call: () => Promise -): void { - setPendingId(pendingKey) - void call() - .catch((error) => { - toast.error( - translate('components.native-chat.composer.optionUpdateFailed', 'Could not update option'), - { description: optionUpdateFailureDescription(error) } - ) - }) - .finally(() => setPendingId(null)) -} - function NativeChatSessionOptionPickersInner({ surface, snapshot, @@ -245,10 +217,14 @@ function NativeChatSessionOptionPickersInner({ : null const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { - runSurfaceCall(descriptor.id, setPendingId, () => surface.setOption(descriptor.id, value)) + runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => + surface.setOption(descriptor.id, value) + ) } const invokeAction = (descriptor: SessionOptionDescriptor): void => { - runSurfaceCall(descriptor.id, setPendingId, () => surface.invokeAction(descriptor.id)) + runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => + surface.invokeAction(descriptor.id) + ) } const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 05415627775d..9c78dcd4c300 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -89,6 +89,7 @@ export function NativeChatStructuredSession( composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, hostStopping: hostExecution.stopping, + hostOptionStamp: hostExecution.optionStamp, providerStarting: hostExecution.phase === 'starting', providerRunning: hostExecution.phase !== null, rewind: rewindHost, diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 56e93c60e602..9b56d52c6373 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -63,6 +63,24 @@ export function useStructuredAgentSessionStatusSummary( return { summary, observation } } +/** The option values the host's status carries, as one stamp: a pick made on another device moves + * it, so a chat re-reads its options. An older host's status carries no permission mode. */ +export function useStructuredAgentSessionOptionStamp( + sessionId: string, + target: RuntimeClientTarget +): string { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => { + const summary = feed.getSnapshot().get(sessionId) + return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + }, + () => '' + ) +} + /** Only the host's startup phase, so a chat re-renders when that changes, not on every status. */ export function useStructuredAgentSessionHostExecutionPhase( sessionId: string, @@ -91,14 +109,20 @@ export function useStructuredAgentSessionHostStopping( ) } -/** The host's startup phase and its word on a Stop, each re-rendering the chat only on a change. */ +/** The host's startup phase, its word on a Stop and its option stamp, each re-rendering the chat + * only on a change. */ export function useStructuredAgentSessionHostExecution( sessionId: string, target: RuntimeClientTarget -): { phase: ReturnType; stopping: boolean } { +): { + phase: ReturnType + stopping: boolean + optionStamp: string +} { return { phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target), - stopping: useStructuredAgentSessionHostStopping(sessionId, target) + stopping: useStructuredAgentSessionHostStopping(sessionId, target), + optionStamp: useStructuredAgentSessionOptionStamp(sessionId, target) } } diff --git a/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts new file mode 100644 index 000000000000..fd03acb34cfd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts @@ -0,0 +1,81 @@ +import type { LucideIcon } from 'lucide-react' +import { FilePenLine, Hand, ShieldAlert, ShieldCheck } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode' + +export const NATIVE_CHAT_PERMISSION_MODE_ICONS = { + ask: Hand, + 'accept-edits': FilePenLine, + auto: ShieldCheck, + 'full-access': ShieldAlert +} as const satisfies Record + +export function nativeChatPermissionModeLabel(mode: AgentChatPermissionMode): string { + switch (mode) { + case 'ask': + return translate('components.native-chat.composer.permissionAsk', 'Ask for approval') + case 'accept-edits': + return translate('components.native-chat.composer.permissionAcceptEdits', 'Accept edits') + case 'auto': + return translate('components.native-chat.composer.permissionAuto', 'Approve for me') + case 'full-access': + return translate('components.native-chat.composer.permissionFullAccess', 'Full access') + } +} + +/** `agent` absent describes the mode for every agent, as the new-chat setting does. */ +export function nativeChatPermissionModeDescription( + mode: AgentChatPermissionMode, + agent?: string | null +): string { + switch (mode) { + case 'ask': + if (agent === 'codex') { + return translate( + 'components.native-chat.composer.permissionAskCodexDescription', + 'Works inside the workspace sandbox; asks before going beyond it' + ) + } + if (agent !== 'claude') { + return translate( + 'components.native-chat.composer.permissionAskSharedDescription', + 'Claude asks unless your settings allow it; Codex asks beyond the workspace sandbox' + ) + } + return translate( + 'components.native-chat.composer.permissionAskDescription', + "Asks before edits and commands your settings don't allow" + ) + case 'accept-edits': + return translate( + 'components.native-chat.composer.permissionAcceptEditsDescription', + 'Approves file edits and file commands; asks for the rest' + ) + case 'auto': + return translate( + 'components.native-chat.composer.permissionAutoDescription', + 'Reviews approval requests for you' + ) + case 'full-access': + if (agent === 'codex') { + return translate( + 'components.native-chat.composer.permissionFullAccessCodexDescription', + 'Never asks; no sandbox' + ) + } + if (agent !== 'claude') { + return translate( + 'components.native-chat.composer.permissionFullAccessSharedDescription', + 'Skips approval prompts; Codex also runs without its sandbox' + ) + } + return translate( + 'components.native-chat.composer.permissionFullAccessDescription', + 'Skips approval prompts; your Claude rules and sandbox still apply' + ) + } +} + +export function nativeChatPermissionPickerTitle(): string { + return translate('components.native-chat.composer.permissions', 'Permissions') +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-surface-call.ts b/src/renderer/src/components/native-chat/native-chat-session-option-surface-call.ts new file mode 100644 index 000000000000..a314a7e8ac4f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-surface-call.ts @@ -0,0 +1,31 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { agentSessionWriteFailureText } from './agent-session-write-notice-text' + +/** A host's error is words for its log, so it gets the table's; a local surface's error is + * already written for the person. */ +function optionUpdateFailureDescription(error: unknown): string { + if (error instanceof RuntimeRpcCallError) { + return agentSessionWriteFailureText(agentSessionThrownFailure(error, error.code), 'option') + } + return error instanceof Error ? error.message : String(error) +} + +/** One picker write: marks `pendingKey` until it settles and reports a failure as a toast. */ +export function runSessionOptionSurfaceCall( + pendingKey: string, + setPendingId: (id: string | null) => void, + call: () => Promise +): void { + setPendingId(pendingKey) + void call() + .catch((error) => { + toast.error( + translate('components.native-chat.composer.optionUpdateFailed', 'Could not update option'), + { description: optionUpdateFailureDescription(error) } + ) + }) + .finally(() => setPendingId(null)) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index a2ec9d0c1efa..d7e7de8c2319 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -47,6 +47,8 @@ export function useStructuredAgentSessionOptionState(args: { seedsModel: boolean providerVisible: boolean providerStarting: boolean + /** The option values the host's status carries: a pick made on another device moves it. */ + hostOptionStamp: string readsBeforeStart: boolean turnId: string | null unloadedTurnRevisions: number | undefined @@ -54,6 +56,7 @@ export function useStructuredAgentSessionOptionState(args: { const { agent, fence, + hostOptionStamp, identity, newLaunch, optionCatalog, @@ -115,10 +118,12 @@ export function useStructuredAgentSessionOptionState(args: { ...(launch.worktree ? { worktree: launch.worktree } : {}) }) // A host answer is the account's, not the fence's: keep it rather than blank the default. - const next = + const kept = sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin') ? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource } : seeded + // The chat's own, which a restart for a new mode keeps: the picker never blanks between them. + const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 pendingOptionRef.current = null optionStateRef.current = next @@ -127,8 +132,9 @@ export function useStructuredAgentSessionOptionState(args: { }, [agent, fence, identity]) const optionsReadRef = useRef(null) - // Refresh options each turn to confirm which model the provider actually selected, and once - // the provider starts: only then has the host read what it will run. + // Refresh options each turn to confirm which model the provider actually selected, once the + // provider starts (only then has the host read what it will run), and when another device's pick + // moves the host's status. useEffect(() => { if (!providerVisible || (providerStarting && !readsBeforeStart) || !optionCatalog) { return @@ -167,6 +173,7 @@ export function useStructuredAgentSessionOptionState(args: { }, [ fence, optionCatalog, + hostOptionStamp, providerStarting, providerVisible, readsBeforeStart, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx new file mode 100644 index 000000000000..9cc826cc7dc6 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom + +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: vi.fn() +})) + +vi.mock('@/lib/structured-agent-session-launch-options', () => ({ + holdStructuredAgentSessionLaunchOption: vi.fn(), + getStructuredAgentSessionLaunchSelection: () => null +})) + +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' + +const mutate: StructuredAgentSessionMutate = vi.fn(async () => null) +const LOCAL_TARGET = { kind: 'local' } as const + +function optionsAnswering(...modes: (string | undefined)[]): void { + let read = 0 + mocks.call.mockImplementation((_target: unknown, method: string) => { + if (method !== 'agentSession.options') { + return new Promise(() => {}) + } + const permissionMode = modes[Math.min(read++, modes.length - 1)] + return Promise.resolve({ + models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }], + current: { model: 'model-a', ...(permissionMode ? { permissionMode } : {}) } + }) + }) +} + +const optionReads = () => + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.options').length + +function permission(snapshot: readonly SessionOptionDescriptor[]): string | null | undefined { + const entry = snapshot.find((descriptor) => descriptor.id === 'permissionMode') + return entry?.kind.type === 'select' ? entry.kind.currentValue : null +} + +function renderReopened(fence: number) { + return renderHook( + (props: { fence: number; stamp: string }) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL_TARGET, + transportEnabled: true, + isVisible: true, + providerVisible: true, + fence: props.fence, + turnId: null, + unloadedTurnRevisions: undefined, + hostOptionStamp: props.stamp, + mutate + }), + { initialProps: { fence, stamp: 'model-a|ask' } } + ) +} + +describe('the permission mode another device picks', () => { + beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() + }) + + it('re-reads the options once when the host status moves, and shows the new mode', async () => { + optionsAnswering('ask', 'full-access') + const { result, rerender, unmount } = renderReopened(1) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) + expect(optionReads()).toBe(1) + + rerender({ fence: 1, stamp: 'model-a|full-access' }) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('full-access')) + expect(optionReads()).toBe(2) + unmount() + }) + + // A restart for a new mode moves the fence; the pill never blanks before the next read answers. + it('keeps the mode across a fence of the same chat', async () => { + optionsAnswering('auto') + const { result, rerender, unmount } = renderReopened(1) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('auto')) + + mocks.call.mockImplementation(() => new Promise(() => {})) + rerender({ fence: 2, stamp: 'model-a|ask' }) + expect(permission(result.current.optionSnapshot)).toBe('auto') + unmount() + }) + + it('shows no picker for a host that states no mode', async () => { + optionsAnswering(undefined) + const { result, unmount } = renderReopened(1) + await waitFor(() => expect(optionReads()).toBe(1)) + await waitFor(() => + expect(result.current.optionSnapshot.some((descriptor) => descriptor.id === 'model')).toBe( + true + ) + ) + expect(permission(result.current.optionSnapshot)).toBeNull() + unmount() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 0bdfa0f6b2ea..812a31525564 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -43,6 +43,7 @@ export function useStructuredAgentSessionOptions(args: { providerVisible: boolean providerStarting?: boolean providerRunning?: boolean + hostOptionStamp?: string fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined @@ -86,6 +87,7 @@ export function useStructuredAgentSessionOptions(args: { seedsModel: launchSeedOptions?.model !== undefined, providerVisible, providerStarting: args.providerStarting ?? false, + hostOptionStamp: args.hostOptionStamp ?? '', // A new chat's host knows nothing of its model before the provider starts; a resumed one's // holds the model it ran, so only the former waits rather than show the host's guess. readsBeforeStart: launch?.kind !== 'new', diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 95b025b2de38..44ddb56b790a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -63,6 +63,8 @@ export function useStructuredAgentSession(args: { queueFollowUps?: boolean /** The host says a person's Stop is still ending this session's work. */ hostStopping?: boolean + /** The option values the host's status carries; a change re-reads the chat's options. */ + hostOptionStamp?: string /** The host's rewind latch and what follows a message returned by a rewind. */ rewind?: NativeChatRewindHost }) { @@ -114,6 +116,7 @@ export function useStructuredAgentSession(args: { providerVisible, providerStarting, ...(args.providerRunning ? { providerRunning: true } : {}), + ...(args.hostOptionStamp ? { hostOptionStamp: args.hostOptionStamp } : {}), fence: state.fence, turnId: transportState.turnId, unloadedTurnRevisions: state.unloadedTurnRevisions, diff --git a/src/renderer/src/components/settings/ChatPermissionSetting.test.tsx b/src/renderer/src/components/settings/ChatPermissionSetting.test.tsx new file mode 100644 index 000000000000..2fee837c3658 --- /dev/null +++ b/src/renderer/src/components/settings/ChatPermissionSetting.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { ChatPermissionSetting } from './ChatPermissionSetting' +import { TooltipProvider } from '../ui/tooltip' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (value: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) +afterEach(cleanup) + +// A web client unpaired from its host holds no value, so it shows no control. +it.each([undefined, 'ask', 'full-access'] as const)( + 'offers the new-chat default only for a host value %s', + (mode) => { + const { container } = render( + + + + ) + if (mode === undefined) { + expect(container.querySelector('#chat-permissions')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + } else { + expect( + screen.getByRole('button', { + name: `Permissions ${mode === 'ask' ? 'Ask for approval' : 'Full access'}` + }) + ).toBeTruthy() + } + } +) diff --git a/src/renderer/src/components/settings/ChatPermissionSetting.tsx b/src/renderer/src/components/settings/ChatPermissionSetting.tsx new file mode 100644 index 000000000000..4817f6a966e4 --- /dev/null +++ b/src/renderer/src/components/settings/ChatPermissionSetting.tsx @@ -0,0 +1,43 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { AGENT_CHAT_PERMISSION_MODES } from '../../../../shared/agent-chat-permission-mode' +import { NativeChatPermissionModePicker } from '../native-chat/NativeChatPermissionModePicker' +import { SettingsSubsectionHeader } from './SettingsFormControls' +import { SearchableSetting } from './SearchableSetting' +import { getChatPermissionSearchEntry } from './chat-permission-search' + +/** This host's mode for new Claude and Codex chats; each chat keeps its own once created. */ +export function ChatPermissionSetting({ + settings, + updateSettings, + forceVisible +}: { + settings: GlobalSettings + updateSettings: (updates: Partial) => void + forceVisible: boolean +}): React.JSX.Element | null { + const current = settings.nativeChatPermissionMode + if (current === undefined) { + return null + } + const entry = getChatPermissionSearchEntry() + return ( + + updateSettings({ nativeChatPermissionMode })} + /> + } + /> + + ) +} diff --git a/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx index 0f023f1ae052..ea62d17f8fef 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { ChatSettingsSection } from './ChatSettingsSection' +import { TooltipProvider } from '../ui/tooltip' import { ActiveSettingsSectionProvider } from './SettingsSection' import { getChatInlineVisualsSearchEntry } from './chat-inline-visuals-search' import { matchesSettingsSearch } from './settings-search' @@ -45,7 +46,8 @@ function renderChat(nativeChatInlineVisuals: boolean | undefined) { showDesktopOnlySettings isMounted /> - + , + { wrapper: TooltipProvider } ), updateSettings } diff --git a/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx index a274675bc0a0..ccceac5108f7 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { buildSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata' import { ChatSettingsSection } from './ChatSettingsSection' +import { TooltipProvider } from '../ui/tooltip' import { ActiveSettingsSectionProvider } from './SettingsSection' import { getChatAppearanceSearchEntries } from './chat-appearance-search' import { getChatNamingSearchEntry } from './chat-naming-search' @@ -91,21 +92,23 @@ function NavigationHarness({ terminal as SettingsTerminalModel ) return ( - - {activeSectionId} - {}} - searchEntries={[ - ...getChatAppearanceSearchEntries(), - getChatNamingSearchEntry(), - getChatInlineVisualsSearchEntry() - ]} - showDesktopOnlySettings - isMounted - /> - + + + {activeSectionId} + {}} + searchEntries={[ + ...getChatAppearanceSearchEntries(), + getChatNamingSearchEntry(), + getChatInlineVisualsSearchEntry() + ]} + showDesktopOnlySettings + isMounted + /> + + ) } diff --git a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx index 701f06526dbd..222777e92473 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { ChatSettingsSection } from './ChatSettingsSection' +import { TooltipProvider } from '../ui/tooltip' import { ActiveSettingsSectionProvider } from './SettingsSection' import { getChatAppearanceSearchEntries } from './chat-appearance-search' import { getChatNamingSearchEntry } from './chat-naming-search' @@ -59,7 +60,7 @@ function renderChat( /> ) - return { ...render(element()), element, updateSettings } + return { ...render(element(), { wrapper: TooltipProvider }), element, updateSettings } } describe('Chat settings page', () => { diff --git a/src/renderer/src/components/settings/ChatSettingsSection.tsx b/src/renderer/src/components/settings/ChatSettingsSection.tsx index 0eb46c38a4c0..07078414234e 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.tsx @@ -1,6 +1,7 @@ import { translate } from '@/i18n/i18n' import { AppearanceChatSection } from './AppearanceChatSection' import { ChatNamingSetting } from './ChatNamingSetting' +import { ChatPermissionSetting } from './ChatPermissionSetting' import { NativeChatInlineVisualsSetting } from './NativeChatInlineVisualsSetting' import { SettingsSection } from './SettingsSection' import { SettingsSubsectionHeader } from './SettingsFormControls' @@ -50,6 +51,11 @@ export function ChatSettingsSection({ > {isMounted ? (
+ {matchesSettingsSearch(query, [{ title }, ...getChatAppearanceSearchEntries()]) ? (
diff --git a/src/renderer/src/components/settings/chat-naming-persistence.test.tsx b/src/renderer/src/components/settings/chat-naming-persistence.test.tsx index 48e502bbf79a..30c59dde8b21 100644 --- a/src/renderer/src/components/settings/chat-naming-persistence.test.tsx +++ b/src/renderer/src/components/settings/chat-naming-persistence.test.tsx @@ -20,6 +20,7 @@ import { type SettingsPersistenceModel } from './settings-persistence-test-fixture' import { ActiveSettingsSectionProvider } from './SettingsSection' +import { TooltipProvider } from '../ui/tooltip' vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) @@ -36,7 +37,11 @@ function ChatPersistenceHarness({ model }: { model: SettingsPersistenceModel }) view: { isSectionMounted: () => true } } satisfies ChatSettingsRenderContext const section = renderChatSettingsSection(context) - return {section} + return ( + + {section} + + ) } it('passes the existing paired-web capability through the actual Chat renderer', () => { diff --git a/src/renderer/src/components/settings/chat-permission-search.ts b/src/renderer/src/components/settings/chat-permission-search.ts new file mode 100644 index 000000000000..8c0cd837ce78 --- /dev/null +++ b/src/renderer/src/components/settings/chat-permission-search.ts @@ -0,0 +1,12 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import type { SettingsSearchEntry } from './settings-search' + +export const getChatPermissionSearchEntry = createLocalizedCatalog((): SettingsSearchEntry => ({ + targetSectionId: 'chat-permissions', + title: translate('settings.chat.permissionsTitle', 'Permissions for new chats'), + description: translate( + 'settings.chat.permissionsDescription', + 'Applies to new chats only. Agents you open in a terminal use Settings → Agents → Agent Permissions.' + ) +})) diff --git a/src/renderer/src/hooks/settings-navigation-interface-sections.ts b/src/renderer/src/hooks/settings-navigation-interface-sections.ts index a40064076d55..8e3bbfc80d7d 100644 --- a/src/renderer/src/hooks/settings-navigation-interface-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-interface-sections.ts @@ -2,6 +2,7 @@ import { getAppearancePaneSearchEntries } from '@/components/settings/appearance import { getChatAppearanceSearchEntries } from '@/components/settings/chat-appearance-search' import { getChatNamingSearchEntry } from '@/components/settings/chat-naming-search' import { getChatInlineVisualsSearchEntry } from '@/components/settings/chat-inline-visuals-search' +import { getChatPermissionSearchEntry } from '@/components/settings/chat-permission-search' import { getInputPaneSearchEntries } from '@/components/settings/input-search' import { getNotificationsPaneSearchEntries } from '@/components/settings/notifications-search' import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-search' @@ -47,6 +48,7 @@ export function buildInterfaceSettingsSections({ ), icon: MessageSquare, searchEntries: [ + getChatPermissionSearchEntry(), ...getChatAppearanceSearchEntries(), ...(showDesktopOnlySettings ? [getChatNamingSearchEntry(), getChatInlineVisualsSearchEntry()] diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 14662c5928c9..a35be0b2459a 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2848,7 +2848,8 @@ }, "composer": { "effort": "Effort", - "imageUnsupported": "Image paste is not supported for this agent." + "imageUnsupported": "Image paste is not supported for this agent.", + "permissions": "permissions" }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b9893ea9a6fe..e0a9b026aea4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -181,6 +181,8 @@ }, "settings": { "chat": { + "permissionsTitle": "Permissions for new chats", + "permissionsDescription": "Applies to new chats only. Agents you open in a terminal use Settings → Agents → Agent Permissions.", "description": "Choose how chats look and behave.", "names": { "title": "Chat names", @@ -18321,6 +18323,19 @@ "attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.", "attachmentUnreadable": "Couldn't read the dropped files.", "tooManyAttachments": "Attach {{value0}} or fewer files at a time.", + "permissions": "permissions", + "permissionAsk": "Ask for approval", + "permissionAskDescription": "Asks before edits and commands your settings don't allow", + "permissionAskCodexDescription": "Works inside the workspace sandbox; asks before going beyond it", + "permissionAskSharedDescription": "Claude asks unless your settings allow it; Codex asks beyond the workspace sandbox", + "permissionAcceptEdits": "Accept edits", + "permissionAcceptEditsDescription": "Approves file edits and file commands; asks for the rest", + "permissionAuto": "Approve for me", + "permissionAutoDescription": "Reviews approval requests for you", + "permissionFullAccess": "Full access", + "permissionFullAccessDescription": "Skips approval prompts; your Claude rules and sandbox still apply", + "permissionFullAccessCodexDescription": "Never asks; no sandbox", + "permissionFullAccessSharedDescription": "Skips approval prompts; Codex also runs without its sandbox", "pasteFailed": "Paste failed.", "commandAttachmentsUnsupported": "Remove attachments before using a chat-session command." }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fd461e136136..fffee1c9d9c0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16,6 +16,8 @@ }, "settings": { "chat": { + "permissionsTitle": "Permisos para chats nuevos", + "permissionsDescription": "Se aplica solo a chats nuevos. Los agentes que abres en una terminal usan Configuración → Agentes → Permisos de agentes.", "description": "Elige cómo se ven y se nombran los chats.", "names": { "title": "Nombres de chats", @@ -18031,6 +18033,19 @@ "attachmentOwnerChanged": "Este espacio de trabajo cambió de host durante la conexión; vuelve a soltar los archivos.", "attachmentUnreadable": "No se pudieron leer los archivos soltados.", "tooManyAttachments": "Adjunta {{value0}} archivos o menos a la vez.", + "permissions": "permisos", + "permissionAsk": "Pedir aprobación", + "permissionAskDescription": "Pregunta antes de ediciones y comandos que tu configuración no permite", + "permissionAskCodexDescription": "Trabaja dentro del entorno aislado del espacio de trabajo; pregunta antes de salir de él", + "permissionAskSharedDescription": "Claude pregunta salvo que tu configuración lo permita; Codex pregunta fuera del sandbox del espacio de trabajo", + "permissionAcceptEdits": "Aceptar ediciones", + "permissionAcceptEditsDescription": "Aprueba ediciones y comandos de archivos; pregunta por lo demás", + "permissionAuto": "Aprobar por mí", + "permissionAutoDescription": "Revisa las solicitudes de aprobación por ti", + "permissionFullAccess": "Acceso total", + "permissionFullAccessDescription": "Omite las solicitudes de aprobación; tus reglas y sandbox de Claude siguen aplicándose", + "permissionFullAccessCodexDescription": "Nunca pregunta; sin sandbox", + "permissionFullAccessSharedDescription": "Omite las solicitudes de aprobación; Codex además se ejecuta sin su sandbox", "pasteFailed": "Error al pegar.", "commandAttachmentsUnsupported": "Quita los archivos adjuntos antes de usar un comando de la sesión de chat." }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3c20a21befbd..a36c109015c8 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -117,6 +117,8 @@ }, "settings": { "chat": { + "permissionsTitle": "Autorisations pour les nouveaux chats", + "permissionsDescription": "S’applique uniquement aux nouveaux chats. Les agents ouverts dans un terminal utilisent Paramètres → Agents → Autorisations des agents.", "description": "Choisissez l’apparence et le nom des chats.", "names": { "title": "Noms des chats", @@ -18133,6 +18135,19 @@ "attachmentOwnerChanged": "Cet espace de travail a changé d'hôte lors de la connexion : supprimez à nouveau les fichiers.", "attachmentUnreadable": "Impossible de lire les fichiers supprimés.", "tooManyAttachments": "Joignez {{value0}} fichiers maximum à la fois.", + "permissions": "autorisations", + "permissionAsk": "Demander l’approbation", + "permissionAskDescription": "Demande avant les modifications et commandes que vos réglages n'autorisent pas", + "permissionAskCodexDescription": "Travaille dans le bac à sable de l’espace de travail ; demande avant d’en sortir", + "permissionAskSharedDescription": "Claude demande sauf si vos réglages l'autorisent ; Codex demande au-delà du bac à sable de l'espace de travail", + "permissionAcceptEdits": "Accepter les modifications", + "permissionAcceptEditsDescription": "Approuve les modifications et commandes de fichiers ; demande pour le reste", + "permissionAuto": "Approuver pour moi", + "permissionAutoDescription": "Examine les demandes d’approbation pour vous", + "permissionFullAccess": "Accès complet", + "permissionFullAccessDescription": "Ignore les demandes d'approbation ; vos règles et le bac à sable de Claude s'appliquent toujours", + "permissionFullAccessCodexDescription": "Ne demande jamais ; sans bac à sable", + "permissionFullAccessSharedDescription": "Ignore les demandes d'approbation ; Codex s'exécute aussi sans son bac à sable", "pasteFailed": "Échec du collage.", "commandAttachmentsUnsupported": "Retirez les pièces jointes avant d'utiliser une commande de session de chat." }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 71a55e8dd371..c629c5a6f414 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16,6 +16,8 @@ }, "settings": { "chat": { + "permissionsTitle": "新しいチャットの権限", + "permissionsDescription": "新しいチャットにのみ適用されます。ターミナルで開くAgentには、設定 → Agent → Agentの権限が適用されます。", "description": "チャットの外観と名前の付け方を選びます。", "names": { "title": "チャット名", @@ -18066,6 +18068,19 @@ "attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。", "attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。", "tooManyAttachments": "一度に添付できるファイルは {{value0}} 個以下です。", + "permissions": "権限", + "permissionAsk": "承認を求める", + "permissionAskDescription": "設定で許可されていない編集やコマンドの前に確認します", + "permissionAskCodexDescription": "ワークスペースのサンドボックス内で動作し、その範囲を超える前に確認します", + "permissionAskSharedDescription": "Claude は設定で許可されていなければ確認します。Codex はワークスペースのサンドボックス外で確認します", + "permissionAcceptEdits": "編集を許可", + "permissionAcceptEditsDescription": "ファイルの編集とファイル操作コマンドを承認し、それ以外は確認します", + "permissionAuto": "代わりに承認", + "permissionAutoDescription": "承認リクエストを代わりに審査します", + "permissionFullAccess": "フルアクセス", + "permissionFullAccessDescription": "承認の確認を省略します。Claude のルールとサンドボックスは引き続き適用されます", + "permissionFullAccessCodexDescription": "確認しません。サンドボックスなし", + "permissionFullAccessSharedDescription": "承認の確認を省略します。Codex はサンドボックスも使いません", "pasteFailed": "貼り付けに失敗しました。", "commandAttachmentsUnsupported": "チャットセッションのコマンドを使う前に添付ファイルを削除してください。" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cb599463cd4b..046582b64e88 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16,6 +16,8 @@ }, "settings": { "chat": { + "permissionsTitle": "새 채팅의 권한", + "permissionsDescription": "새 채팅에만 적용됩니다. 터미널에서 여는 에이전트는 설정 → 에이전트 → 에이전트 권한을 사용합니다.", "description": "채팅 모양과 이름 지정 방식을 선택하세요.", "names": { "title": "채팅 이름", @@ -18066,6 +18068,19 @@ "attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.", "attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.", "tooManyAttachments": "한 번에 {{value0}}개 이하의 파일을 첨부하세요.", + "permissions": "권한", + "permissionAsk": "승인 요청", + "permissionAskDescription": "설정에서 허용하지 않은 편집과 명령 전에 묻습니다", + "permissionAskCodexDescription": "작업 공간 샌드박스 안에서 작업하며, 범위를 벗어나기 전에 묻습니다", + "permissionAskSharedDescription": "Claude는 설정에서 허용하지 않으면 묻고, Codex는 워크스페이스 샌드박스를 벗어날 때 묻습니다", + "permissionAcceptEdits": "편집 허용", + "permissionAcceptEditsDescription": "파일 편집과 파일 명령은 승인하고, 나머지는 묻습니다", + "permissionAuto": "대신 승인", + "permissionAutoDescription": "승인 요청을 대신 검토합니다", + "permissionFullAccess": "전체 액세스", + "permissionFullAccessDescription": "승인 요청을 건너뜁니다. Claude 규칙과 샌드박스는 계속 적용됩니다", + "permissionFullAccessCodexDescription": "묻지 않습니다. 샌드박스 없음", + "permissionFullAccessSharedDescription": "승인 요청을 건너뜁니다. Codex는 샌드박스 없이 실행됩니다", "pasteFailed": "붙여넣기 실패.", "commandAttachmentsUnsupported": "채팅 세션 명령을 사용하기 전에 첨부 파일을 제거하세요." }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 590cb456dd94..75aee696b6b3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16,6 +16,8 @@ }, "settings": { "chat": { + "permissionsTitle": "新聊天的权限", + "permissionsDescription": "仅适用于新聊天。在终端中打开的智能体使用设置 → 智能体 → 智能体权限。", "description": "选择聊天的外观和命名方式。", "names": { "title": "聊天名称", @@ -18031,6 +18033,19 @@ "attachmentOwnerChanged": "此工作区在附加时更改了主机 - 再次删除文件。", "attachmentUnreadable": "无法读取删除的文件。", "tooManyAttachments": "每次最多附加 {{value0}} 个文件。", + "permissions": "权限", + "permissionAsk": "请求批准", + "permissionAskDescription": "在设置未允许的编辑和命令前询问", + "permissionAskCodexDescription": "在工作区沙箱内操作,超出范围前会询问", + "permissionAskSharedDescription": "Claude 会询问,除非设置已允许;Codex 在超出工作区沙盒时询问", + "permissionAcceptEdits": "接受编辑", + "permissionAcceptEditsDescription": "批准文件编辑和文件命令;其余操作会询问", + "permissionAuto": "替我批准", + "permissionAutoDescription": "代你审核批准请求", + "permissionFullAccess": "完全访问", + "permissionFullAccessDescription": "跳过审批提示;你的 Claude 规则和沙盒仍然适用", + "permissionFullAccessCodexDescription": "从不询问;没有沙盒", + "permissionFullAccessSharedDescription": "跳过审批提示;Codex 还会在没有沙盒的情况下运行", "pasteFailed": "粘贴失败。", "commandAttachmentsUnsupported": "使用聊天会话命令前,请先移除附件。" }, diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 4968a95d78c4..0b843806812b 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -188,7 +188,7 @@ describe('structured agent session launch', () => { try { expect( createStructuredAgentSessionLaunchIntent('workspace-1', 'codex', 'local').seedOptions - ).toEqual({ model: 'gpt-5.5' }) + ).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' }) expect( createStructuredAgentSessionLaunchIntent('workspace-1', 'codex', 'runtime:server-1') .seedOptions @@ -317,7 +317,7 @@ describe('structured agent session launch', () => { }) try { expect(restore('runtime:server-1').seedOptions).toEqual({ model: 'opus' }) - expect(restore('local').seedOptions).toEqual({ model: 'sonnet' }) + expect(restore('local').seedOptions).toEqual({ model: 'sonnet', permissionMode: 'ask' }) } finally { useAppStore.setState({ settings }) } diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 3b2d0c778f26..8d288dcf6595 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -6,7 +6,7 @@ import { type StructuredAgentSessionFirstMessage, type StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' -import { resolveStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' +import { structuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { useAppStore } from '@/store' import { @@ -63,7 +63,7 @@ function launchSeedOptions( ): { seedOptions?: Readonly> } { const seedOptions = owner.target.kind === 'local' - ? resolveStructuredLaunchSeedOptions(state.settings?.nativeChatSessionOptions, agent) + ? structuredLaunchSeedOptions(state.settings, agent) : hostSeedOptions return seedOptions ? { seedOptions } : {} } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 5f5bde375d00..430b40be6e08 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -3,7 +3,8 @@ import type { AgentSessionMutationResult, AgentSessionOptionResult } from '../../../shared/agent-session-wire' -import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from '../../../shared/native-chat-session-option-defaults' +import { STRUCTURED_LAUNCH_OPTION_IDS } from '../../../shared/native-chat-session-option-defaults' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint @@ -68,16 +69,18 @@ export function holdStructuredAgentSessionLaunchOption( return null } const replies = repliesFor(state) - const { held } = state.selection - // A model pick drops picks held under the model it replaces. - for (const key of id === 'model' ? Object.keys(held) : [id]) { + const held = { ...state.selection.held } + // A model pick drops picks held under the model it replaces; the permission mode is the chat's. + const dropped = + id === 'model' + ? Object.keys(held).filter((key) => key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) + : [id] + for (const key of dropped) { replies.get(key)?.({ kind: 'superseded' }) replies.delete(key) + delete held[key] } - state.selection = { - ...state.selection, - held: id === 'model' ? { model: encoded } : { ...held, [id]: encoded } - } + state.selection = { ...state.selection, held: { ...held, [id]: encoded } } notifyStructuredLaunchListeners() return new Promise((resolve) => replies.set(id, resolve)) } @@ -169,7 +172,7 @@ export async function applyStructuredLaunchHeldOptions( throw new StructuredAgentSessionLaunchCancelledError() } const { held } = state.selection - const id = STRUCTURED_LAUNCH_SEED_OPTION_IDS.find((key) => held[key] !== undefined) + const id = STRUCTURED_LAUNCH_OPTION_IDS.find((key) => held[key] !== undefined) const encoded = id ? held[id] : undefined if (!id || encoded === undefined) { return receipt diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index b9b626dbb2e0..b8f3cb688623 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -1,3 +1,4 @@ +import { isAgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode' import { normalizeNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' import { @@ -189,3 +190,13 @@ export function mergeSettings( }) } } + +/** The host's new-chat permission default, kept only when it is a mode this build knows. The host + * seeds new chats from its own, so web reads and edits that one rather than a local copy. */ +export function runtimeChatPermissionSetting( + settings: Partial +): Pick { + return isAgentChatPermissionMode(settings.nativeChatPermissionMode) + ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } + : {} +} diff --git a/src/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts index 0f96d27144da..3c46c8175e18 100644 --- a/src/renderer/src/web/preload-api/web-preferences-store.ts +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -20,7 +20,11 @@ import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' import { normalizeUiLanguage } from '../../../../shared/ui-language' import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment' -import { mergeSettings, mergeWebUIState } from './web-preference-normalization' +import { + mergeSettings, + mergeWebUIState, + runtimeChatPermissionSetting +} from './web-preference-normalization' import { callRuntimeResult } from './web-runtime-calls' import { requireActiveEnvironmentOrNull, webRuntimeState } from './web-runtime-session' import { zcodePlanSiteOwner, settingsForZcodePlanSiteOwner } from './web-zcode-plan-site' @@ -160,6 +164,7 @@ export async function getRuntimeBackedStoredSettings(): Promise result.settings.prBotAuthorOverrides ) } + Object.assign(runtimeSettings, runtimeChatPermissionSetting(result.settings)) // Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never // sends it back, so web shows what the host enforces instead of a local value it ignores. if (typeof result.settings.artifactSharingEnabled === 'boolean') { @@ -229,6 +234,7 @@ export async function syncRuntimeBackedSettings( updates.prBotAuthorOverrides ) } + Object.assign(runtimeUpdates, runtimeChatPermissionSetting(updates)) if (Object.keys(runtimeUpdates).length === 0) { return localNext } diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts new file mode 100644 index 000000000000..59829cfea030 --- /dev/null +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_CHAT_PERMISSION_MODES, + CLAUDE_CHAT_PERMISSION_MODES, + CODEX_CHAT_PERMISSION_MODES, + agentChatPermissionModes, + chatPermissionModeNeedsModelSupport, + effectiveChatPermissionMode, + loadedChatPermissionSetting, + newChatPermissionMode, + offeredChatPermissionModes +} from './agent-chat-permission-mode' +import { getDefaultSettings } from './constants' +import { applyAgentPermissionMode } from './tui-agent-permissions' + +describe('agent chat permission modes', () => { + it('orders the picker from least to most access', () => { + expect(AGENT_CHAT_PERMISSION_MODES).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) + }) + + it('offers Claude every mode, Codex no edits-only mode, and any other agent none', () => { + expect(agentChatPermissionModes('claude')?.modes).toEqual(AGENT_CHAT_PERMISSION_MODES) + expect(agentChatPermissionModes('codex')?.modes).toEqual(['ask', 'auto', 'full-access']) + expect(agentChatPermissionModes('grok')).toBeNull() + }) + + 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. + expect(newChatPermissionMode('claude', 'auto')).toBe('auto') + expect(newChatPermissionMode('codex', 'full-access')).toBe('full-access') + expect(newChatPermissionMode('claude', 'retired-mode')).toBe('ask') + expect(newChatPermissionMode('claude', undefined)).toBe('ask') + expect(newChatPermissionMode('grok', 'full-access')).toBeNull() + }) +}) + +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') + }) + + it('offers Approve for me only where the model runs it', () => { + expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, false)).toEqual([ + 'ask', + 'accept-edits', + 'full-access' + ]) + expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, true)).toEqual( + AGENT_CHAT_PERMISSION_MODES + ) + expect(offeredChatPermissionModes(CODEX_CHAT_PERMISSION_MODES, false)).toEqual([ + 'ask', + 'auto', + 'full-access' + ]) + }) +}) + +describe('the new-chat setting as loaded', () => { + it.each([ + ['yolo', 'full-access'], + ['manual', 'ask'] + ] as const)('seeds %s Agent Permissions as %s', (mode, expected) => { + expect(loadedChatPermissionSetting(undefined, applyAgentPermissionMode({ mode }))).toBe( + expected + ) + }) + + it('seeds mixed Agent Permissions as Ask', () => { + const terminal = applyAgentPermissionMode({ mode: 'yolo' }) + terminal.agentDefaultArgs.claude = '' + expect(loadedChatPermissionSetting(undefined, terminal)).toBe('ask') + }) + + it('seeds a fresh profile from the shipped Yolo defaults', () => { + expect(loadedChatPermissionSetting(undefined, getDefaultSettings('/tmp'))).toBe('full-access') + expect(getDefaultSettings('/tmp').nativeChatPermissionMode).toBe('full-access') + }) + + it.each(AGENT_CHAT_PERMISSION_MODES)( + 'keeps a saved %s whatever Agent Permissions say', + (mode) => { + expect(loadedChatPermissionSetting(mode, applyAgentPermissionMode({ mode: 'manual' }))).toBe( + mode + ) + expect(loadedChatPermissionSetting(mode, applyAgentPermissionMode({ mode: 'yolo' }))).toBe( + mode + ) + } + ) + + it.each(['future', null, 42, 'bypass'])('reads an unknown saved %s as Ask', (value) => { + expect(loadedChatPermissionSetting(value, getDefaultSettings('/tmp'))).toBe('ask') + }) +}) diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts new file mode 100644 index 000000000000..922c77000cec --- /dev/null +++ b/src/shared/agent-chat-permission-mode.ts @@ -0,0 +1,104 @@ +import type { GlobalSettings } from './global-settings-types' +import { resolveAgentPermissionModeSummary } from './tui-agent-permissions' + +/** A chat's own permission choice. Saved in chat records and settings: never rename a value. */ +export type AgentChatPermissionMode = 'ask' | 'accept-edits' | 'auto' | 'full-access' + +/** Picker order: least to most access. */ +export const AGENT_CHAT_PERMISSION_MODES = [ + 'ask', + 'accept-edits', + 'auto', + 'full-access' +] as const satisfies readonly AgentChatPermissionMode[] + +/** The chat option a mode is saved and written under. */ +export const AGENT_CHAT_PERMISSION_MODE_OPTION_ID = 'permissionMode' + +export function isAgentChatPermissionMode(value: unknown): value is AgentChatPermissionMode { + return AGENT_CHAT_PERMISSION_MODES.some((mode) => mode === value) +} + +/** The modes one agent's chats offer. */ +export type AgentChatPermissionModeSupport = { + modes: readonly AgentChatPermissionMode[] + /** `auto` runs only on a model the agent's listing says supports it. */ + autoNeedsModelSupport?: true +} + +export const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: AGENT_CHAT_PERMISSION_MODES, + autoNeedsModelSupport: true +} + +export const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: ['ask', 'auto', 'full-access'] +} + +/** Null for an agent whose chats have no picker: it keeps following Agent Permissions. */ +export function agentChatPermissionModes(agent: string): AgentChatPermissionModeSupport | null { + return agent === 'claude' + ? CLAUDE_CHAT_PERMISSION_MODES + : agent === 'codex' + ? CODEX_CHAT_PERMISSION_MODES + : null +} + +/** The saved choice if this agent offers it; anything else asks, never grants more access. */ +export function savedChatPermissionMode( + support: AgentChatPermissionModeSupport, + intent: unknown +): AgentChatPermissionMode { + return support.modes.find((mode) => mode === intent) ?? 'ask' +} + +/** Whether the mode a chat runs depends on its model's listed `auto` support. */ +export function chatPermissionModeNeedsModelSupport( + support: AgentChatPermissionModeSupport, + intent: unknown +): boolean { + return ( + support.autoNeedsModelSupport === true && savedChatPermissionMode(support, intent) === 'auto' + ) +} + +/** What a chat runs and shows: its saved choice, with `auto` on a model not listed for it read as + * Ask. Derived on every read and never written back, so a supporting model gets `auto` again. */ +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 +): readonly AgentChatPermissionMode[] { + return support.autoNeedsModelSupport && !autoSupported + ? support.modes.filter((mode) => mode !== 'auto') + : support.modes +} + +/** The host's new-chat default as an agent's chat starts with it; null for an agent with none. */ +export function newChatPermissionMode( + agent: string, + setting: unknown +): AgentChatPermissionMode | null { + const support = agentChatPermissionModes(agent) + return support ? savedChatPermissionMode(support, setting) : null +} + +/** The new-chat default as loaded. Seeded once from Agent Permissions when absent; once stored it + * no longer follows them. An unknown value from another build asks. */ +export function loadedChatPermissionSetting( + saved: unknown, + terminal: Partial> +): AgentChatPermissionMode { + if (saved !== undefined) { + return isAgentChatPermissionMode(saved) ? saved : 'ask' + } + return resolveAgentPermissionModeSummary(terminal) === 'yolo' ? 'full-access' : 'ask' +} diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index 3ade08e6427e..ea631ebc4ee9 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -51,6 +51,8 @@ export type CatalogModel = { isDefault?: boolean /** Tokens the model's context window holds, where the host's listing states it. */ contextWindowTokens?: number + /** The listing says the model runs Approve for me; absent is unknown, which offers it nowhere. */ + supportsAutoMode?: boolean options: CatalogOption[] } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 520db20cc33a..4f2552ae4b06 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -248,6 +248,8 @@ export type AgentSessionStatusSummary = { latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string + /** The chat's saved permission mode; a client re-reads options when it or `model` moves. */ + permissionMode?: string /** The tool the running turn is inside, else the last one it used. Absent unless `status` * is 'working'. */ toolName?: string @@ -397,6 +399,9 @@ export type AgentSessionModelOption = { efforts: AgentSessionOptionChoice[] /** Provider catalog fact. Absent means the host could not determine support. */ supportsFastMode?: boolean + /** Whether the model runs the agent's own approval reviewer (Approve for me). Absent is unknown, + * which neither offers nor launches it. */ + supportsAutoMode?: boolean /** Service tiers the provider lists for this model besides its standard one, each valued by the * provider's own tier id. Absent means unknown; a client that reads it offers one speed choice * (`default` for standard) in place of the Fast toggle. */ @@ -473,9 +478,7 @@ export type AgentSessionThreadGoalChange = | { kind: 'status'; status: 'active' | 'paused' } | { kind: 'clear' } -export type AgentSessionThreadGoalResult = { - change: AgentSessionThreadGoalChange['kind'] -} +export type AgentSessionThreadGoalResult = { change: AgentSessionThreadGoalChange['kind'] } /** Provider-reported choices and effective next-turn values. Additive read-only * surface so older hosts can reject it without changing structured v1 writes. */ @@ -501,6 +504,9 @@ export type AgentSessionOptionsResult = { fastMode?: boolean /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ serviceTier?: string + /** The chat's saved permission mode, which the next start of its agent runs. Absent from a + * host or agent with no permission picker. */ + permissionMode?: string /** Provider-reported effective routing, distinct from the next-turn preference. */ fastModeState?: AgentSessionFastModeState /** diff --git a/src/shared/claude-model-list-probe.ts b/src/shared/claude-model-list-probe.ts index 9be7f5c3a120..096dc2ce6011 100644 --- a/src/shared/claude-model-list-probe.ts +++ b/src/shared/claude-model-list-probe.ts @@ -31,6 +31,8 @@ export type ClaudeListedModel = { /** `--effort` values this model accepts; empty when it has no effort control. */ effortLevels: string[] supportsFastMode: boolean + /** Absent when the CLI predates the field: unknown, never unsupported. */ + supportsAutoMode?: boolean } const CLAUDE_MODEL_LIST_JSON_LIMITS = { @@ -53,6 +55,7 @@ type RawListedModel = { supportsEffort?: unknown supportedEffortLevels?: unknown supportsFastMode?: unknown + supportsAutoMode?: unknown disabled?: unknown } @@ -80,7 +83,8 @@ function toListedModel(value: unknown): ClaudeListedModel | null { label, ...(description ? { description } : {}), effortLevels, - supportsFastMode: raw.supportsFastMode === true + supportsFastMode: raw.supportsFastMode === true, + ...(typeof raw.supportsAutoMode === 'boolean' ? { supportsAutoMode: raw.supportsAutoMode } : {}) } } diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index bd47acc5bac9..35f5b4c119c0 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -37,6 +37,8 @@ export type CommitMessageModel = { defaultThinkingLevel?: string /** Whether the model exposes Claude's mid-session Fast mode toggle. */ supportsFastMode?: boolean + /** Whether Claude's listing says the model runs its auto permission mode; absent is unknown. */ + supportsAutoMode?: boolean /** Set when the listing marks this as the id the CLI runs with no --model flag. * Optional so an older remote host that never reports it simply omits it. */ isDefault?: boolean diff --git a/src/shared/commit-message-model-parsers.ts b/src/shared/commit-message-model-parsers.ts index 072033e305e0..30b88e82e12c 100644 --- a/src/shared/commit-message-model-parsers.ts +++ b/src/shared/commit-message-model-parsers.ts @@ -87,7 +87,10 @@ export function parseClaudeModels(stdout: string): CommitMessageModel[] { : thinkingLevels[0].id } : {}), - ...(model.supportsFastMode ? { supportsFastMode: true } : {}) + ...(model.supportsFastMode ? { supportsFastMode: true } : {}), + ...(model.supportsAutoMode !== undefined + ? { supportsAutoMode: model.supportsAutoMode } + : {}) } }) ) diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 39e42cfdc763..178ca2e19d5e 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -9,6 +9,7 @@ import { DEFAULT_APP_ICON_ID } from './app-icon' import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications' import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection' import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults' +import { loadedChatPermissionSetting } from './agent-chat-permission-mode' import { UI_LANGUAGE_SYSTEM } from './ui-language' import { DEFAULT_LEFT_SIDEBAR_TINT_COLOR, @@ -139,6 +140,10 @@ export function buildDefaultSettings(args: { nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [], nativeChatSessionOptions: {}, + nativeChatPermissionMode: loadedChatPermissionSetting(undefined, { + agentDefaultArgs: DEFAULT_TUI_AGENT_ARGS, + agentDefaultEnv: DEFAULT_TUI_AGENT_ENV + }), openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 97ab0925e9f1..fee97b1592a4 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -14,6 +14,7 @@ import type { TerminalCustomTheme } from './terminal-custom-themes' import type { UiLanguage } from './ui-language' import type { GlobalWindowsRuntimeDefault } from './project-execution-runtime' import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' +import type { AgentChatPermissionMode } from './agent-chat-permission-mode' import type { ComputerAwakeMode } from './computer-awake-mode' import type { CommitMessageAiSettings } from './commit-message-ai-types' import type { HostSettingOverrides } from './host-setting-overrides' @@ -233,6 +234,9 @@ export type GlobalSettings = NativeChatGlobalSettings & { nativeChatShellEnvironmentVariables?: string[] /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ nativeChatSessionOptions?: PersistedNativeChatSessionOptions + /** This host's permission mode for new Claude and Codex chats; each chat keeps its own after. + * Seeded once from Agent Permissions when a profile first loads without it. */ + nativeChatPermissionMode?: AgentChatPermissionMode /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ openInApplications?: OpenInApplication[] /** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */ diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index 6a8e6be0db3a..ab365c5e6fec 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -1,4 +1,10 @@ import type { AgentType } from './agent-status-types' +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + isAgentChatPermissionMode, + newChatPermissionMode +} from './agent-chat-permission-mode' +import type { GlobalSettings } from './global-settings-types' import { sessionOptionValueIsValid } from './agent-session-option-catalog' import type { NativeChatSessionOptionSettingsMutation, @@ -42,6 +48,13 @@ export const STRUCTURED_LAUNCH_SEED_OPTION_IDS = [ 'serviceTier' ] as const +/** What a launch may hold and a create may seed: the saved selection plus the chat's permission + * mode, which seeds from its own setting, so a chat's pick never rewrites the saved selection. */ +export const STRUCTURED_LAUNCH_OPTION_IDS = [ + ...STRUCTURED_LAUNCH_SEED_OPTION_IDS, + AGENT_CHAT_PERMISSION_MODE_OPTION_ID +] as const + /** Any chosen option set narrowed to what a structured create may seed: the seedable ids only, * each encoded as a bounded string. An empty result is `undefined` rather than `{}` — an empty map fails * the durable record's bounded-string guard, and `agent_session_options_invalid` is not a wire @@ -88,6 +101,10 @@ export function parseStructuredLaunchSeedOptions( parsed[id] = encoded } } + const permissionMode = entries.get(AGENT_CHAT_PERMISSION_MODE_OPTION_ID) + if (isAgentChatPermissionMode(permissionMode)) { + parsed[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] = permissionMode + } return Object.keys(parsed).length > 0 ? parsed : undefined } @@ -99,6 +116,22 @@ export function resolveStructuredLaunchSeedOptions( return narrowStructuredLaunchSeedOptions(resolveNativeChatSessionOptionDefaults(persisted, agent)) } +/** Everything a new chat of `agent` on this host starts with: the saved selection and the host's + * permission default, narrowed to what the agent offers. */ +export function structuredLaunchSeedOptions( + settings: + | Pick + | null + | undefined, + agent: AgentType +): Record | undefined { + const selection = resolveStructuredLaunchSeedOptions(settings?.nativeChatSessionOptions, agent) + const permissionMode = newChatPermissionMode(agent, settings?.nativeChatPermissionMode) + return permissionMode + ? { ...selection, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode } + : selection +} + /** Fold a settled batch of picks onto the durable record. A surface that must send the * whole object back — rather than merging key by key — applies them in one pass so a * later pick in the batch cannot drop an earlier one. */ diff --git a/src/shared/native-chat-session-option-snapshot.ts b/src/shared/native-chat-session-option-snapshot.ts index c2d17f800aa7..76d123823cad 100644 --- a/src/shared/native-chat-session-option-snapshot.ts +++ b/src/shared/native-chat-session-option-snapshot.ts @@ -1,3 +1,4 @@ +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' import type { AgentSessionOptionCatalog, CatalogMidSessionApply, @@ -147,13 +148,16 @@ const CATEGORY_ORDER: Record = { mode: 2 } -/** The snapshot's non-model descriptors in display order. Shared so the two - * platforms' option pills can never disagree about ordering. */ +/** The snapshot's per-model descriptors, besides the model, in display order. Shared so the two + * platforms' option pills can never disagree about ordering. The permission mode has its own pill. */ export function sortNativeChatSessionOptions( snapshot: readonly SessionOptionDescriptor[] ): SessionOptionDescriptor[] { return snapshot - .filter((descriptor) => descriptor.category !== 'model') + .filter( + (descriptor) => + descriptor.category !== 'model' && descriptor.id !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID + ) .sort((left, right) => { const leftOrder = CATEGORY_ORDER[left.category ?? ''] ?? 3 const rightOrder = CATEGORY_ORDER[right.category ?? ''] ?? 3 diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts index 928fecfe6d82..bebab8d31bb1 100644 --- a/src/shared/rpc-contract/client-settings-params.ts +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -10,6 +10,7 @@ import { import { MACHINE_NAME_MAX_LENGTH } from '../machine-name' import { normalizePRBotAuthorOverrides } from '../pr-bot-author-overrides' import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-params' +import { AGENT_CHAT_PERMISSION_MODES } from '../agent-chat-permission-mode' export const TaskProviderParam = z.custom(isTaskProvider, { message: 'Unknown task provider' @@ -123,7 +124,8 @@ export const SettingsUpdate = z prBotAuthorOverrides: z .unknown() .transform((value) => normalizePRBotAuthorOverrides(value)) - .optional() + .optional(), + nativeChatPermissionMode: z.enum(AGENT_CHAT_PERMISSION_MODES).optional() }) .strict() .default({}) diff --git a/src/shared/structured-agent-session-discovered-model.ts b/src/shared/structured-agent-session-discovered-model.ts index 56c1f42b67e2..6878f4d0ae96 100644 --- a/src/shared/structured-agent-session-discovered-model.ts +++ b/src/shared/structured-agent-session-discovered-model.ts @@ -57,6 +57,7 @@ export function discoveredModel( label: model.label, ...(model.description ? { description: model.description } : {}), ...(model.isDefault ? { isDefault: true } : {}), + ...(model.supportsAutoMode !== undefined ? { supportsAutoMode: model.supportsAutoMode } : {}), options: [ ...(effort ? [effort] : []), ...(serviceTier diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index c19d56ed4cdd..15d6ec7800d0 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -3,6 +3,7 @@ import { type StructuredAgentSessionOptionState } from './structured-agent-session-options' import { cloneNativeChatSessionOptionRecord } from './native-chat-session-option-state' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' function catalogListsSeedModel( state: StructuredAgentSessionOptionState, @@ -17,11 +18,25 @@ function catalogListsSeedModel( * `seed` (the selection a launch seeds) stands in until the record names a * model, and `held` picks outrank both until the host settles them. Both show * as `dispatched`; derived on every read, never written into the record. + * + * The permission mode reads its own way: the held pick, else what the host stated, else the seed. */ export function structuredAgentSessionOptionView( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, held: Readonly> +): StructuredAgentSessionOptionState { + const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: heldPermission, ...heldSelection } = held + const view = modelOptionView(state, seed, heldSelection) + const permissionMode = + heldPermission ?? state.permissionMode ?? seed?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? null + return view.permissionMode === permissionMode ? view : { ...view, permissionMode } +} + +function modelOptionView( + state: StructuredAgentSessionOptionState, + seed: Readonly> | undefined, + held: Readonly> ): StructuredAgentSessionOptionState { const unconfirmedSeed = seed !== undefined && state.record.model === undefined const hasHeld = Object.keys(held).length > 0 diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 5d7af56ba8c0..2e2019d2e6db 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -12,7 +12,12 @@ import { setTrackedSessionOption, type NativeChatSessionOptionRecord } from './native-chat-session-option-state' -import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults' +import { + STRUCTURED_LAUNCH_OPTION_IDS, + STRUCTURED_LAUNCH_SEED_OPTION_IDS +} from './native-chat-session-option-defaults' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' +import { structuredAgentSessionPermissionDescriptor } from './structured-agent-session-permission-option' import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' import type { AgentSessionModelCatalogResult, @@ -47,6 +52,9 @@ export type StructuredAgentSessionOptionState = { * same list once the host said it has none, usable but naming nothing the host may replace. */ catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null record: NativeChatSessionOptionRecord + /** The chat's saved permission mode as the host or the launch last stated it; null until one + * does, which shows no permission picker. */ + permissionMode: string | null pendingId: string | null } @@ -60,6 +68,7 @@ export function createStructuredAgentSessionOptionState( catalog: seedCatalog ?? null, catalogSource: seedCatalog ? 'seed' : null, record: createNativeChatSessionOptionRecord(agent), + permissionMode: null, pendingId: null } } @@ -138,7 +147,8 @@ export function applyStructuredAgentSessionOptions( return { ...state, catalog: structuredAgentSessionOptionCatalog(seed, result), - catalogSource: 'live' + catalogSource: 'live', + permissionMode: result.current.permissionMode ?? null } } @@ -158,6 +168,12 @@ const PROVIDER_DEFAULT_MODEL_PLACEHOLDER: SessionOptionDescriptor = { export function structuredAgentSessionOptionSnapshot( state: StructuredAgentSessionOptionState ): SessionOptionDescriptor[] { + const permission = structuredAgentSessionPermissionDescriptor(state) + const options = modelOptionSnapshot(state) + return permission ? [...options, permission] : options +} + +function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionOptionDescriptor[] { if (!state.catalog) { return [] } @@ -211,6 +227,9 @@ export function commitStructuredAgentSessionOption( id: string, value: string ): StructuredAgentSessionOptionState { + if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { + return { ...state, permissionMode: value, pendingId: null } + } if (!state.catalog) { return state } @@ -232,7 +251,7 @@ export function commitStructuredAgentSessionOptionValues( values: Readonly> ): StructuredAgentSessionOptionState { let next = state - for (const id of STRUCTURED_LAUNCH_SEED_OPTION_IDS) { + for (const id of STRUCTURED_LAUNCH_OPTION_IDS) { const value = values[id] if (value !== undefined) { next = commitStructuredAgentSessionOption(next, id, value) diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts new file mode 100644 index 000000000000..443d2565aabf --- /dev/null +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOptionsResult } from './agent-session-wire' +import { sortNativeChatSessionOptions } from './native-chat-session-option-snapshot' +import { + applyStructuredAgentSessionOptions, + commitStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionPicks, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-options' +import { structuredAgentSessionOptionView } from './structured-agent-session-option-view' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' + +const permission = (state: Parameters[0]) => + structuredAgentSessionOptionSnapshot(state).find( + (descriptor) => descriptor.id === 'permissionMode' + ) + +function live(agent: string, current: AgentSessionOptionsResult['current'], autoMode?: boolean) { + const seed = structuredAgentSessionSeedCatalog(agent) + return applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState(agent, seed), + seed, + { + models: [ + { + id: 'model-a', + label: 'Model A', + isDefault: true, + efforts: [], + ...(autoMode === undefined ? {} : { supportsAutoMode: autoMode }) + } + ], + current: { model: 'model-a', ...current } + } + ) +} + +describe('the permission mode picker', () => { + it('shows the seeded mode before the agent starts, beside the quiet model pill', () => { + const state = createStructuredAgentSessionOptionState( + 'codex', + structuredAgentSessionSeedCatalog('codex') + ) + const view = structuredAgentSessionOptionView(state, { permissionMode: 'full-access' }, {}) + expect(structuredAgentSessionOptionSnapshot(view).map((descriptor) => descriptor.id)).toEqual([ + 'model', + 'permissionMode' + ]) + expect(permission(view)).toMatchObject({ + settable: true, + kind: { currentValue: 'full-access' } + }) + }) + + it('shows nothing for a host that states no mode, or an agent with none', () => { + expect(permission(live('codex', {}))).toBeUndefined() + expect(permission(live('grok', { permissionMode: 'ask' }))).toBeUndefined() + }) + + it("offers Codex's three modes with its own descriptions", () => { + const descriptor = permission(live('codex', { permissionMode: 'ask' })) + expect(descriptor?.kind.type === 'select' && descriptor.kind.choices).toEqual([ + { + value: 'ask', + label: 'Ask for approval', + description: 'Works inside the workspace sandbox; asks before going beyond it' + }, + { value: 'auto', label: 'Approve for me', description: 'Reviews approval requests for you' }, + { value: 'full-access', label: 'Full access', description: 'Never asks; no sandbox' } + ]) + }) + + it('offers and shows Claude auto only on a model listed as supporting it', () => { + const supported = permission(live('claude', { permissionMode: 'auto' }, true)) + expect(supported).toMatchObject({ kind: { currentValue: 'auto' } }) + for (const autoMode of [false, undefined]) { + const narrowed = permission(live('claude', { permissionMode: 'auto' }, autoMode)) + expect(narrowed).toMatchObject({ kind: { currentValue: 'ask' } }) + expect( + narrowed?.kind.type === 'select' && narrowed.kind.choices.map((choice) => choice.value) + ).toEqual(['ask', 'accept-edits', 'full-access']) + } + }) + + it("shows a held pick over the host's value, and the host's over the seed", () => { + const state = live('codex', { permissionMode: 'auto' }) + const seed = { permissionMode: 'ask' } + expect(permission(structuredAgentSessionOptionView(state, seed, {}))).toMatchObject({ + kind: { currentValue: 'auto' } + }) + expect( + permission(structuredAgentSessionOptionView(state, seed, { permissionMode: 'full-access' })) + ).toMatchObject({ kind: { currentValue: 'full-access' } }) + }) + + it('takes a settled pick, and the record a write answers with', () => { + const state = live('codex', { permissionMode: 'ask' }) + expect(commitStructuredAgentSessionOption(state, 'permissionMode', 'auto').permissionMode).toBe( + 'auto' + ) + expect( + commitStructuredAgentSessionOptionValues(state, { + model: 'model-a', + permissionMode: 'full-access' + }).permissionMode + ).toBe('full-access') + }) + + it('stays out of the per-model option pill and out of the saved selection', () => { + const state = live('codex', { permissionMode: 'ask' }) + expect( + sortNativeChatSessionOptions(structuredAgentSessionOptionSnapshot(state)).map( + (descriptor) => descriptor.id + ) + ).not.toContain('permissionMode') + expect(structuredAgentSessionOptionPicks(state, { permissionMode: 'full-access' })).toEqual([]) + }) +}) diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts new file mode 100644 index 000000000000..64f5f218a95b --- /dev/null +++ b/src/shared/structured-agent-session-permission-option.ts @@ -0,0 +1,80 @@ +// The chat's permission mode as a picker descriptor, beside the model's but never locked by it: it +// shows before the agent starts, and only a host that states a mode gets one. + +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + agentChatPermissionModes, + effectiveChatPermissionMode, + offeredChatPermissionModes, + type AgentChatPermissionMode +} from './agent-chat-permission-mode' +import type { AgentSessionOptionCatalog } from './agent-session-option-catalog' +import { resolveEffectiveNativeChatModelId } from './native-chat-session-option-snapshot' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-state' +import type { SessionOptionDescriptor } from './native-chat-session-options' + +/** English names and descriptions for surfaces without a translation catalog. */ +const MODE_COPY: Readonly> = + { + ask: { + label: 'Ask for approval', + description: "Asks before edits and commands your settings don't allow" + }, + 'accept-edits': { + label: 'Accept edits', + description: 'Approves file edits and file commands; asks for the rest' + }, + auto: { label: 'Approve for me', description: 'Reviews approval requests for you' }, + 'full-access': { + label: 'Full access', + description: 'Skips approval prompts; your Claude rules and sandbox still apply' + } + } + +const CODEX_DESCRIPTIONS: Partial> = { + ask: 'Works inside the workspace sandbox; asks before going beyond it', + 'full-access': 'Never asks; no sandbox' +} + +/** Whether the model the chat runs is listed as running Approve for me; unknown reads as no. */ +function currentModelSupportsAutoMode( + catalog: AgentSessionOptionCatalog | null, + record: NativeChatSessionOptionRecord +): boolean { + if (!catalog) { + return false + } + const modelId = resolveEffectiveNativeChatModelId(catalog, catalog.models, record) + return catalog.models.find((model) => model.id === modelId)?.supportsAutoMode === true +} + +export function structuredAgentSessionPermissionDescriptor(state: { + catalog: AgentSessionOptionCatalog | null + record: NativeChatSessionOptionRecord + permissionMode: string | null +}): SessionOptionDescriptor | null { + const support = agentChatPermissionModes(state.record.agent) + if (!support || state.permissionMode === null) { + return null + } + const autoSupported = currentModelSupportsAutoMode(state.catalog, state.record) + return { + id: AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + label: 'Permissions', + category: 'mode', + kind: { + type: 'select', + currentValue: effectiveChatPermissionMode(support, state.permissionMode, autoSupported), + choices: offeredChatPermissionModes(support, autoSupported).map((value) => ({ + value, + label: MODE_COPY[value].label, + description: + (state.record.agent === 'codex' ? CODEX_DESCRIPTIONS[value] : undefined) ?? + MODE_COPY[value].description + })) + }, + valueSource: 'applied', + transport: 'agent-session', + settable: true + } +} From 994be666ce897c7e48041794225399ebe83c34fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:31:53 -0700 Subject: [PATCH 2/7] test(native-chat): drop type assertions from permission-mode tests --- ...e-structured-agent-options.permission-mode.test.ts | 6 +++--- .../claude/claude-agent-sdk-contract-pins.test.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index 4dcc805e55b3..3a2f113f1b87 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -50,9 +50,9 @@ type ProbeProps = { client: RpcClient; fence: number; optionStamp: string } const mutate: StructuredAgentSessionMutate = vi.fn() async function mountOptions(props: ProbeProps) { - let rendered: Controller | null = null + const rendered: { current: Controller | null } = { current: null } function Probe(probe: ProbeProps): null { - rendered = useMobileStructuredAgentOptions({ + rendered.current = useMobileStructuredAgentOptions({ agent: 'codex', client: probe.client, sessionId: 'session-1', @@ -75,7 +75,7 @@ async function mountOptions(props: ProbeProps) { await settle() return { permission: () => { - const entry = (rendered as Controller | null)?.optionSnapshot.find( + const entry = rendered.current?.optionSnapshot.find( (descriptor) => descriptor.id === 'permissionMode' ) return entry?.kind.type === 'select' ? entry.kind.currentValue : null diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index 5dd419aba3ae..f335b832c05d 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -151,7 +151,16 @@ function resolvedLaunch(permissionMode: AgentChatPermissionMode, launchArgs: str resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, resolveAuthPolicy: () => ({ account: 'managed' }) - })({ identity: { sessionId: record.sessionId } as never, permissionMode }) + })({ + identity: { + sessionId: record.sessionId, + workspaceId: 'workspace-1', + hostId: LOCAL_EXECUTION_HOST_ID, + agent: 'claude', + providerHandle: null + }, + permissionMode + }) } function singleUserTurn(): AsyncIterable { From 98c14038d5f186d0c2645cee2c14b57df8753874 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:19:24 -0700 Subject: [PATCH 3/7] fix(native-chat): the host alone decides the permission mode a chat runs The restart check, the start and the options read now share one host derivation, so a running agent whose mode differs from what a start would launch is replaced, and the button shows what the host runs. A permission pick shows at once and never locks the picker while its write waits behind a starting agent. One shared table names each agent's modes; the phone shows its permission pill even without a model pill. --- ...bileNativeChatSessionOptionPickers.test.ts | 31 ++++++++ .../MobileNativeChatSessionOptionPickers.tsx | 30 ++++---- ...ured-agent-options.permission-mode.test.ts | 18 +++++ .../use-mobile-structured-agent-options.ts | 53 +++++++++----- ...-mobile-structured-session-option-stamp.ts | 6 +- .../claude-structured-agent-definition.ts | 4 +- .../claude-structured-launch-resolution.ts | 11 +-- .../codex-structured-agent-definition.ts | 4 +- .../codex/codex-structured-session-state.ts | 3 +- .../structured-agent-definition.ts | 3 - ...ured-agent-session-attach-orchestration.ts | 2 +- .../structured-agent-session-host-types.ts | 3 +- ...ed-agent-session-launch-permission.test.ts | 21 +++--- ...uctured-agent-session-launch-permission.ts | 39 ++++------ ...uctured-agent-session-options-read.test.ts | 4 +- .../structured-agent-session-options-read.ts | 33 ++++----- ...tured-agent-session-outgrown-child.test.ts | 73 +++++++++++++++++-- ...structured-agent-session-outgrown-child.ts | 26 +++---- .../structured-agent-session-runtime.ts | 3 +- .../NativeChatPermissionModePicker.test.tsx | 3 + .../NativeChatPermissionModePicker.tsx | 17 ++--- .../StructuredAgentSessionStatusBridge.tsx | 6 +- ...e-structured-agent-session-option-state.ts | 6 +- ...t-session-options.permission-mode.test.tsx | 42 ++++++++++- .../use-structured-agent-session-options.ts | 25 +++++-- .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- .../web-preference-normalization.ts | 3 +- src/shared/agent-chat-permission-mode.test.ts | 5 +- src/shared/agent-chat-permission-mode.ts | 12 ++- src/shared/agent-session-wire.ts | 6 +- src/shared/global-settings-types.ts | 3 +- .../native-chat-session-option-defaults.ts | 6 +- .../structured-agent-session-options.ts | 3 +- ...ed-agent-session-permission-option.test.ts | 21 +++--- ...uctured-agent-session-permission-option.ts | 18 +++-- 38 files changed, 346 insertions(+), 205 deletions(-) diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index 5a959b870bfa..e43785711f46 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -284,4 +284,35 @@ describe('MobileNativeChatSessionOptionPickers', () => { mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true) expect(pill('Model').props).toMatchObject({ disabled: true }) }) + + it('shows the permission pill before any model pill, and closes at once on a pick', async () => { + mount([PERMISSION_DESCRIPTOR], true) + expect(pill('Permissions').props).toMatchObject({ + accessibilityLabel: 'Permissions, Ask for approval', + disabled: false + }) + // A write that waits behind a starting agent never holds the sheet open. + setOption.mockImplementation(() => new Promise(() => {})) + await act(async () => pill('Permissions').props.onPress()) + await act(async () => rowByText('Full access').props.onPress()) + expect(setOption).toHaveBeenCalledWith('permissionMode', 'full-access') + expect(renderer!.root.findAllByType('BottomDrawer')).toEqual([]) + }) }) + +const PERMISSION_DESCRIPTOR: SessionOptionDescriptor = { + id: 'permissionMode', + label: 'Permissions', + category: 'mode', + kind: { + type: 'select', + currentValue: 'ask', + choices: [ + { value: 'ask', label: 'Ask for approval' }, + { value: 'full-access', label: 'Full access' } + ] + }, + valueSource: 'applied', + transport: 'agent-session', + settable: true +} diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index 0838fe1f0db6..ed0f12584436 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -59,15 +59,15 @@ export function MobileNativeChatSessionOptionPickers({ (descriptor) => descriptor.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ) const options = sortNativeChatSessionOptions(snapshot) - if (!model) { + if (!model && !permission) { return null } const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId) const permissionView = activeDescriptor !== undefined && activeDescriptor === permission // The permission mode applies from the next send, so a running turn never locks it. const disabled = (!permissionView && isWorking) || pendingId !== null || sendInFlight - const modelView = activeDescriptor?.id === model.id - const modelLabel = mobileModelPillLabel(model) + const modelView = model !== undefined && activeDescriptor?.id === model.id + const modelLabel = model ? mobileModelPillLabel(model) : '' const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason) @@ -75,7 +75,7 @@ export function MobileNativeChatSessionOptionPickers({ const closePicker = (): void => setOpenDescriptorId(null) const openPicker = (): void => { Keyboard.dismiss() - setOpenDescriptorId(model.id) + setOpenDescriptorId(model?.id ?? null) } const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { @@ -88,6 +88,10 @@ export function MobileNativeChatSessionOptionPickers({ closePicker() return } + // A permission pick shows at once; its write may wait behind a starting agent. + if (descriptor === permission) { + closePicker() + } void controller.setOption(descriptor.id, value).then((applied) => { if (applied) { closePicker() @@ -122,12 +126,14 @@ export function MobileNativeChatSessionOptionPickers({ }} /> ) : null} - + {model ? ( + + ) : null} {activeDescriptor ? ( @@ -136,9 +142,7 @@ export function MobileNativeChatSessionOptionPickers({ accessibilityLabel={modelView || permissionView ? 'Close picker' : 'Back to models'} accessibilityRole="button" style={({ pressed }) => [styles.sheetNav, pressed && styles.pressed]} - onPress={ - modelView || permissionView ? closePicker : () => setOpenDescriptorId(model.id) - } + onPress={modelView || permissionView ? closePicker : openPicker} hitSlop={8} > {modelView || permissionView ? ( diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index 3a2f113f1b87..fafc3c72a30e 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -86,6 +86,12 @@ async function mountOptions(props: ProbeProps) { }) await settle() }, + pick: async (mode: string) => { + await act(async () => { + void rendered.current?.setStructuredOption('permissionMode', mode) + }) + await settle() + }, unmount: () => act(async () => renderer?.unmount()) } } @@ -116,6 +122,18 @@ describe('the permission mode on the phone', () => { await options.unmount() }) + it('shows a pick at once while its write waits behind a start, and stays pickable', async () => { + vi.mocked(mutate).mockImplementation(() => new Promise(() => {})) + const rpc = optionsClient('ask') + const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' }) + await options.pick('full-access') + expect(options.permission()).toBe('full-access') + await options.pick('auto') + expect(options.permission()).toBe('auto') + expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2) + await options.unmount() + }) + it('shows no pill for a host that states no mode', async () => { const rpc = optionsClient(undefined) const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' }) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index f65c6760b1e1..e07e5ec43b69 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -20,6 +20,7 @@ import { structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode' import type { RpcClient } from '../transport/rpc-client' import { callAgentSession, @@ -168,8 +169,36 @@ export function useMobileStructuredAgentOptions(args: { } const targetRecord = currentState.record const mutationGeneration = ++optionMutationGeneration.current - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) + // Record-only: shown at once, so the picker stays usable while the write waits behind a start. + const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + if (shownAtOnce) { + updateOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOption(current, id, encoded) + : current + ) + } else { + pendingOptionRef.current = id + updateOptionState((current) => ({ ...current, pendingId: id })) + } + const readBack = (): void => { + void callAgentSession(client, 'agentSession.options', { + sessionId + }) + .then((refreshed) => { + if ( + activeOptionRecordRef.current === targetRecord && + optionMutationGeneration.current === mutationGeneration + ) { + updateOptionState((latest) => + latest.record === targetRecord + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => undefined) + } try { const result = await mutate( 'agentSession.setOption', @@ -200,22 +229,7 @@ export function useMobileStructuredAgentOptions(args: { }) } if (result.sameFence) { - void callAgentSession(client, 'agentSession.options', { - sessionId - }) - .then((refreshed) => { - if ( - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - ) { - updateOptionState((latest) => - latest.record === targetRecord - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest - ) - } - }) - .catch(() => undefined) + readBack() } return true } @@ -227,6 +241,9 @@ export function useMobileStructuredAgentOptions(args: { ) return true } + if (shownAtOnce) { + readBack() + } return false } finally { if ( diff --git a/mobile/src/session/use-mobile-structured-session-option-stamp.ts b/mobile/src/session/use-mobile-structured-session-option-stamp.ts index b8706be96ec5..c1696161e89b 100644 --- a/mobile/src/session/use-mobile-structured-session-option-stamp.ts +++ b/mobile/src/session/use-mobile-structured-session-option-stamp.ts @@ -4,11 +4,7 @@ import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-s const noSubscription = (): (() => void) => () => {} -/** - * The option values the host's status carries for session `sessionId`, as one stamp: a pick made - * on another device moves it, so the phone re-reads the session's options, as the desktop does. - * Off `enabled` (no status feed or no connection), it never moves. - */ +/** The host status's option values as one stamp; another device's pick moves it, off `enabled` never. */ export function useMobileStructuredSessionOptionStamp(args: { client: RpcClient | null sessionId: string | null diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts index 36518bd61daa..447ecb79a3be 100644 --- a/src/main/claude/claude-structured-agent-definition.ts +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -2,7 +2,6 @@ import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-sessi import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isClaudeStructuredOptionKey } from './claude-structured-options' import { claudeFallbackModelOptions } from './claude-structured-session-options' -import { CLAUDE_CHAT_PERMISSION_MODES } from '../../shared/agent-chat-permission-mode' export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'claude', @@ -24,6 +23,5 @@ export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { acceptsKey: isClaudeStructuredOptionKey, fallbackModels: claudeFallbackModelOptions, effortDefaultsToModel: true - }, - permissionModes: CLAUDE_CHAT_PERMISSION_MODES + } } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index d267643a33a1..24ac06eb47c1 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -76,17 +76,13 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { } const CLAUDE_PERMISSION_MODES = { + // Explicit, so a `defaultMode` in the user's Claude settings never runs other than the button says. ask: 'default', 'accept-edits': 'acceptEdits', auto: 'auto' } as const satisfies Record, PermissionMode> -/** - * A chat's permission mode as query-start options. - * - * Full access is the owned CLI flag, which preserves the user-installed binary contract: the SDK's - * typed bypass option emits a newer allow flag that older Claude binaries reject before starting. - */ +/** Full access is the owned CLI flag: the SDK's typed bypass emits a flag older binaries reject. */ export function claudeStructuredPermissionOptions( mode: AgentChatPermissionMode ): Pick { @@ -170,8 +166,7 @@ export async function resolveClaudeStructuredInvocation( } } -/** What one start asks of the launch: the conversation, and the permission mode the host decided - * it runs, which absent asks. */ +/** The conversation and the permission mode the host decided it starts with (absent asks). */ export type ClaudeStructuredLaunchInput = { identity: AgentSessionJournalIdentity permissionMode?: AgentChatPermissionMode diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts index 0390aa5b033a..1f06c603000b 100644 --- a/src/main/codex/codex-structured-agent-definition.ts +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -1,7 +1,6 @@ import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isCodexTurnOptionKey } from './codex-structured-turn-start' -import { CODEX_CHAT_PERMISSION_MODES } from '../../shared/agent-chat-permission-mode' export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'codex', @@ -25,6 +24,5 @@ export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { fallbackModels: () => null, // A running child answers only the effort its thread reported, never the model's default. effortDefaultsToModel: false - }, - permissionModes: CODEX_CHAT_PERMISSION_MODES + } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index f3cb50c74d01..a973d2f4a147 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -32,8 +32,7 @@ import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess -/** What one start asks of the launch: the conversation, and the permission mode the host decided - * it runs, which absent asks. */ +/** The conversation and the permission mode the host decided it starts with (absent asks). */ export type CodexStructuredLaunchInput = { identity: AgentSessionJournalIdentity permissionMode?: AgentChatPermissionMode diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index 9c6eab3097f8..5a1e43ce2223 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -7,7 +7,6 @@ import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' -import type { AgentChatPermissionModeSupport } from '../../../shared/agent-chat-permission-mode' /** `agent` names the Orca agent whose sessions this describes; the storage fields bound its records. */ export type StructuredAgentDefinition = AgentSessionStoredAgent & { @@ -21,8 +20,6 @@ export type StructuredAgentDefinition = AgentSessionStoredAgent & { /** An unpicked effort reads as the model's default effort, as a running child reports it. */ effortDefaultsToModel: boolean } - /** The permission modes its chats offer, each a launch policy; absent keeps Agent Permissions. */ - permissionModes?: AgentChatPermissionModeSupport } /** An agent whose account is one config directory, as Claude's and Codex's are. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index edae7213d092..2fdc842b335f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -207,7 +207,7 @@ async function runAttachUnderAbort( }, optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), launchPermissionMode: async (record) => - (await structuredAgentSessionLaunchPermission(context.deps, record))?.mode, + (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 638f153a3bd6..c87dec4421db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -74,8 +74,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** The queued message whose delivery started this child, fixed when the start is made; absent * for any other start. In memory only: it tells a restart offer its own start from another. */ readonly startedFor?: string - /** The permission mode this child was launched with; absent for an agent with none. In memory - * only: a send after the chat's mode changes starts a new child. */ + /** The permission mode it was launched with, in memory only; absent for an agent with none. */ readonly permissionMode?: AgentChatPermissionMode close?: StructuredAgentSessionChildClose } 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 fbac47d519dd..640ea42ed084 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,5 +1,5 @@ -// The mode a start launches with: Claude's Approve for me needs its model listed as supporting it, -// read from the saved catalog only, and an unknown answer is never treated as a change. +// The mode a start launches with: Claude's Approve for me needs its model listed for it in the saved +// catalog; anything the catalog cannot confirm launches Ask. import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -55,18 +55,15 @@ function launchPermission( 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.toEqual({ mode: 'accept-edits', known: true }) + await expect(claude.result).resolves.toBe('accept-edits') expect(claude.read).not.toHaveBeenCalled() const codex = launchPermission('codex', { permissionMode: 'auto' }) - await expect(codex.result).resolves.toEqual({ mode: 'auto', known: true }) + 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.toEqual({ - mode: 'ask', - known: true - }) + await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') }) it('runs Claude auto on a model the saved catalog lists as supporting it', async () => { @@ -75,7 +72,7 @@ describe('the permission mode a chat starts with', () => { { permissionMode: 'auto', model: 'opus' }, listing([{ id: 'opus', supportsAutoMode: true }]) ) - await expect(supported.result).resolves.toEqual({ mode: 'auto', known: true }) + await expect(supported.result).resolves.toBe('auto') expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true })) }) @@ -89,10 +86,10 @@ describe('the permission mode a chat starts with', () => { { id: 'opus', supportsAutoMode: true } ]) ).result - ).resolves.toEqual({ mode: 'ask', known: true }) + ).resolves.toBe('ask') }) - it('asks, as an unknown answer, when the catalog cannot say', async () => { + it('asks when the catalog cannot confirm the model runs auto', async () => { for (const answer of [ { origin: 'unknown' } as const, listing([{ id: 'opus' }]), @@ -101,7 +98,7 @@ describe('the permission mode a chat starts with', () => { ]) { await expect( launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result - ).resolves.toEqual({ mode: 'ask', known: false }) + ).resolves.toBe('ask') } }) 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 a911f942725c..80787e3b5803 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,56 +1,45 @@ -// The permission mode a chat's agent starts with: its saved choice, narrowed by what the host's -// model catalog lists for the chat's model. A start launches with it and the child keeps it. +// The one place that decides a chat's permission mode: what a start launches, a restart compares, a read shows. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + agentChatPermissionModes, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, type AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -export type StructuredAgentSessionLaunchPermission = { - mode: AgentChatPermissionMode - /** False when the mode rests on a model the saved catalog does not describe. */ - known: boolean -} - -/** Saved catalog only: a start never waits on a listing. Matches the model by id, or the listed - * default when the chat names none. */ +/** Saved catalog only, so a start never waits on a listing; a model it cannot name gets no `auto`. */ async function listedAutoModeSupport( catalog: StructuredAgentSessionHostDeps['modelCatalog'], record: AgentSessionRecord -): Promise { +): Promise { const listing = await catalog ?.read({ agent: record.provider, sessionId: record.sessionId, savedOnly: true }) .catch(() => null) if (!listing || listing.origin === 'unknown') { - return undefined + return false } const model = record.options?.model const row = listing.models.find((entry) => model === undefined || model === 'default' ? entry.isDefault : entry.id === model ) - return row?.supportsAutoMode + return row?.supportsAutoMode === true } -/** Null for an agent whose chats offer no modes. */ +/** Null for an agent whose chats offer no modes, or one this runtime did not register. */ export async function structuredAgentSessionLaunchPermission( deps: Pick, record: AgentSessionRecord -): Promise { - const support = deps.agents.definition(record.provider)?.permissionModes - if (!support) { +): Promise { + const support = agentChatPermissionModes(record.provider) + if (!support || !deps.agents.definition(record.provider)) { return null } const intent = record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] - if (!chatPermissionModeNeedsModelSupport(support, intent)) { - return { mode: effectiveChatPermissionMode(support, intent, false), known: true } - } - const supported = await listedAutoModeSupport(deps.modelCatalog, record) - return { - mode: effectiveChatPermissionMode(support, intent, supported === true), - known: supported !== undefined - } + const autoSupported = + chatPermissionModeNeedsModelSupport(support, intent) && + (await listedAutoModeSupport(deps.modelCatalog, record)) + return effectiveChatPermissionMode(support, intent, autoSupported) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 6af3a5f69897..feed99e3e4ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -60,11 +60,11 @@ describe('options at rest', () => { expect(result.models).toEqual([]) expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } - // The resting conversation runs Codex, whose chats state a permission mode. + // Of these, only Codex's chats state a permission mode. expect((await readStructuredAgentSessionOptions(context, SESSION)).current).toEqual({ model: 'later-model', effort: 'off', - permissionMode: 'ask' + ...(provider === 'codex' ? { permissionMode: 'ask' } : {}) }) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index f83cfd4ece8c..23c26dc89891 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -14,7 +14,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, - savedChatPermissionMode + agentChatPermissionModes } from '../../../shared/agent-chat-permission-mode' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' @@ -23,6 +23,7 @@ import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' +import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' @@ -30,12 +31,15 @@ type RestingOptions = Pick, + provider: string, key: string, value: string ): boolean { + const definition = agents.definition(provider) return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - ? definition?.permissionModes?.modes.some((mode) => mode === value) === true + ? definition !== null && + agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true : definition?.restingOptions.acceptsKey(key) === true } @@ -45,9 +49,8 @@ export function structuredAgentSessionOptionOverridesRefusal( provider: string, options: Readonly> ) { - const definition = agents.definition(provider) const key = Object.keys(options).find( - (key) => !acceptsOptionPick(definition, key, options[key] ?? '') + (key) => !acceptsOptionPick(agents, provider, key, options[key] ?? '') ) return key === undefined ? null @@ -121,10 +124,7 @@ export async function recordStructuredAgentSessionOptionIntent( input: { key: string; value: string } ): Promise> { const record = deps.store.getRecord(ctx.sessionId) - if ( - !record || - !acceptsOptionPick(deps.agents.definition(record.provider), input.key, input.value) - ) { + if (!record || !acceptsOptionPick(deps.agents, record.provider, input.key, input.value)) { return { ok: false, refusal: refuse( @@ -193,19 +193,16 @@ export async function readStructuredAgentSessionOptions( } // Re-acquired after the reads above: the handle they saw may have closed and reopened since. const session = await context.conversation(sessionId) - const phase = store.getRecord(sessionId)?.rewind?.phase + const record = store.getRecord(sessionId) + const phase = record?.rewind?.phase const agent = session.params.provider const capabilities = agents.capabilities(agent) const floor = session.journal.context.floor() const contextFloor = floor ? { contextFloor: floor } : {} - // The record's, live or at rest: a child never reports it, and only its next start applies it. - const permissionModes = agents.definition(agent)?.permissionModes - const permissionMode = permissionModes - ? savedChatPermissionMode( - permissionModes, - store.getRecord(sessionId)?.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] - ) - : undefined + // What a start launches now, live or at rest: a child never reports it. + const permissionMode = record + ? await structuredAgentSessionLaunchPermission(context.deps, record) + : null return { ...options, ...(permissionMode ? { current: { ...options.current, 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 a01b8933b8dc..7b0545ae5696 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 @@ -18,7 +18,10 @@ import { retireOutgrownStructuredAgentSessionChild, structuredAgentSessionChildReportedSignedOut } from './structured-agent-session-outgrown-child' -import type { StructuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +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, @@ -208,7 +211,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { input: Parameters[0], work: Partial = {}, stopAgent = vi.fn(async () => undefined), - launchPermission?: () => Promise + launchPermission?: () => Promise ) => { const warn = vi.fn() await retireOutgrownStructuredAgentSessionChild('session', conversation(input), { @@ -271,8 +274,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { }) describe('a child launched with another permission mode', () => { - const now = (mode: StructuredAgentSessionLaunchPermission['mode'], known = true) => - vi.fn(async () => ({ mode, known })) + const now = (mode: AgentChatPermissionMode) => vi.fn(async () => mode) const askChild = { child: childAt({ permissionMode: 'ask' }) } it('is stopped once, and one launched with the current mode is kept', async () => { @@ -292,17 +294,72 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { } }) - it('is kept while starting, closing, or when the current mode is unknown', async () => { + it('is kept while starting, or for an agent with no modes', async () => { const starting = { child: childAt({ permissionMode: 'ask', phase: 'starting' }) } expect((await retire(starting, {}, undefined, now('auto'))).stopAgent).not.toHaveBeenCalled() - expect( - (await retire(askChild, {}, undefined, now('auto', false))).stopAgent - ).not.toHaveBeenCalled() expect( (await retire(askChild, {}, undefined, async () => null)).stopAgent ).not.toHaveBeenCalled() }) + // The check reads the same mode a start launches with, so each start ends the restarts. + describe('against the mode a start launches with', () => { + const rows = [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [] }] + const catalog = (supportsAutoMode?: boolean) => ({ + read: vi.fn(async () => ({ + origin: 'probe' as const, + fetchedAt: 1, + models: rows.map((row) => + supportsAutoMode === undefined ? row : { ...row, supportsAutoMode } + ) + })), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => undefined), + stop: vi.fn(), + providerStarted: vi.fn() + }) + const launch = (options: Record, supportsAutoMode?: boolean) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent and options. + const record = { sessionId: 'session', provider: 'claude', options } as AgentSessionRecord + return () => + structuredAgentSessionLaunchPermission( + { agents: claudeAndCodexDeclared(), modelCatalog: catalog(supportsAutoMode) }, + 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 () => { + for (const supportsAutoMode of [undefined, false]) { + const autoPick = launch({ model: 'opus', permissionMode: 'auto' }, supportsAutoMode) + 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 started from an alias or a resolved model id', async () => { + const records: Record[] = [ + { model: 'opus', permissionMode: 'auto' }, + { model: 'claude-opus-4-5', permissionMode: 'auto' }, + { model: 'default', permissionMode: 'auto' }, + { permissionMode: 'auto' } + ] + for (const options of records) { + const current = launch(options, true) + const launched = await current() + expect(launched).toBe(options.model === 'claude-opus-4-5' ? 'ask' : 'auto') + expect(await stoppedAfterLaunch(launched!, current)).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/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 39257d36a71c..77b90764aabd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -1,6 +1,5 @@ -// A child the chat has outgrown is replaced before the next send: one that reported its agent is not -// signed in, or one launched with a permission mode the chat no longer has. Agents read their saved -// login and their permission mode only when they start, so either change reaches a new child only. +// A child the chat outgrew (signed out, or launched with another permission mode) is replaced before +// the next send: agents read their login and permission mode only when they start. import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -14,10 +13,10 @@ import { type StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import type { StructuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' /** What the check reads of a conversation; every read skips building a snapshot. */ -type SignedOutReading = Pick & { +type OutgrownChildReading = Pick & { journal: Pick< AgentSessionJournal, 'visitItems' | 'visitItemsWithLinkage' | 'itemFence' | 'activeTurnId' | 'newestTurn' @@ -33,7 +32,7 @@ const signedOut = (failure: unknown): boolean => * wrote. Each child holds its own fence, so matching it scopes both to this child; a new one has * nothing. `startUnavailable` is the adapter's read of that child's start. */ export function structuredAgentSessionChildReportedSignedOut( - session: SignedOutReading, + session: OutgrownChildReading, startUnavailable?: AgentSessionUnavailable ): boolean { const { child, journal } = session @@ -80,17 +79,16 @@ function owesWakeUp( ) } -/** The running child was launched with another mode than the chat's. Only a known mode counts, so - * a catalog the host cannot read never restarts a child. */ +/** Loop-free: a start launches with the same `current` this compares against. */ async function launchedWithOtherPermissionMode( - child: SignedOutReading['child'], - current: (() => Promise) | undefined + child: OutgrownChildReading['child'], + current: (() => Promise) | undefined ): Promise { if (!child || child.phase === 'starting' || child.close || !child.permissionMode || !current) { return false } - const permission = await current() - return permission !== null && permission.known && permission.mode !== child.permissionMode + const mode = await current() + return mode !== null && mode !== child.permissionMode } /** For a caller inside the session's serialize, before it hands the next send to the child. Work @@ -98,12 +96,12 @@ async function launchedWithOtherPermissionMode( * the close begun, which the start joins. */ export async function retireOutgrownStructuredAgentSessionChild( sessionId: string, - session: SignedOutReading | undefined, + session: OutgrownChildReading | undefined, deps: { work: StructuredAgentSessionChildWorkReads startUnavailable?: () => AgentSessionUnavailable | undefined /** The mode a start of the chat would launch with now. */ - launchPermission?: () => Promise + launchPermission?: () => Promise stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 21b00d319713..13ff0715be44 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -121,8 +121,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveClaudeLaunchEnv?: () => Promise> | Record /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy - /** The Agent Permissions setting for a protocol-driven (ACP) agent: whether it runs with full - * access. Claude and Codex chats run their own permission mode instead. */ + /** Agent Permissions for an ACP agent; Claude and Codex chats run their own permission mode. */ resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ resolveAgentLaunchEnv?: (agent: string) => Record diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx index f5bdf2a482a0..e306de7648a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx @@ -234,8 +234,11 @@ describe('NativeChatComposerPermissionPicker', () => { ) fireEvent.click(option('ask')) expect(options.setOption).not.toHaveBeenCalled() + // A write that waits behind a starting agent leaves the pill usable. + options.setOption.mockImplementation(() => new Promise(() => {})) fireEvent.click(option('full-access')) expect(options.setOption).toHaveBeenCalledWith('permissionMode', 'full-access') + expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBeNull() }) it('locks only while the descriptor cannot take a pick', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx index 6ea85a725964..49b21aaec912 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from 'react' +import { memo } from 'react' import { ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -112,8 +112,9 @@ function NativeChatPermissionModePickerInner({ export const NativeChatPermissionModePicker = memo(NativeChatPermissionModePickerInner) -/** The composer's permission pill, a view of the snapshot's permission descriptor: absent where the - * host states no mode. Never locked while the agent works; a change applies from the next send. */ +const NO_PENDING_MARK = (): void => {} + +/** The snapshot's permission descriptor, absent where the host states none; never locked by work. */ export function NativeChatComposerPermissionPicker({ surface, snapshot, @@ -123,7 +124,6 @@ export function NativeChatComposerPermissionPicker({ snapshot: readonly SessionOptionDescriptor[] agent?: string }): React.JSX.Element | null { - const [pending, setPending] = useState(false) const descriptor = snapshot.find((entry) => entry.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) const current = descriptor?.kind.type === 'select' ? descriptor.kind.currentValue : undefined if ( @@ -142,13 +142,12 @@ export function NativeChatComposerPermissionPicker({ current={current} modes={modes} agent={agent ?? null} - disabled={!descriptor.settable || pending} + disabled={!descriptor.settable} onSelect={(mode) => { if (mode !== current) { - runSessionOptionSurfaceCall( - mode, - (id) => setPending(id !== null), - () => surface.setOption(AGENT_CHAT_PERMISSION_MODE_OPTION_ID, mode) + // The pick shows at once, so nothing marks it pending. + runSessionOptionSurfaceCall(mode, NO_PENDING_MARK, () => + surface.setOption(AGENT_CHAT_PERMISSION_MODE_OPTION_ID, mode) ) } }} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 9b56d52c6373..3d31f986751f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -63,8 +63,7 @@ export function useStructuredAgentSessionStatusSummary( return { summary, observation } } -/** The option values the host's status carries, as one stamp: a pick made on another device moves - * it, so a chat re-reads its options. An older host's status carries no permission mode. */ +/** The host status's option values as one stamp: another device's pick moves it, so options re-read. */ export function useStructuredAgentSessionOptionStamp( sessionId: string, target: RuntimeClientTarget @@ -109,8 +108,7 @@ export function useStructuredAgentSessionHostStopping( ) } -/** The host's startup phase, its word on a Stop and its option stamp, each re-rendering the chat - * only on a change. */ +/** Startup phase, Stop and option stamp, each re-rendering the chat only on a change. */ export function useStructuredAgentSessionHostExecution( sessionId: string, target: RuntimeClientTarget diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index d7e7de8c2319..7a14963e7282 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -132,9 +132,8 @@ export function useStructuredAgentSessionOptionState(args: { }, [agent, fence, identity]) const optionsReadRef = useRef(null) - // Refresh options each turn to confirm which model the provider actually selected, once the - // provider starts (only then has the host read what it will run), and when another device's pick - // moves the host's status. + // Refresh options each turn to confirm which model the provider actually selected, and once + // the provider starts: only then has the host read what it will run. useEffect(() => { if (!providerVisible || (providerStarting && !readsBeforeStart) || !optionCatalog) { return @@ -173,6 +172,7 @@ export function useStructuredAgentSessionOptionState(args: { }, [ fence, optionCatalog, + // Another device's pick moves it. hostOptionStamp, providerStarting, providerVisible, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx index 9cc826cc7dc6..3041328fadad 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -113,3 +113,43 @@ describe('the permission mode another device picks', () => { unmount() }) }) + +describe('a permission pick on this device', () => { + beforeEach(() => { + mocks.call.mockReset() + vi.mocked(mutate).mockReset() + resetHostModelCatalogSnapshotsForTests() + }) + + it('shows at once and stays pickable while the write waits behind a start', async () => { + optionsAnswering('ask') + vi.mocked(mutate).mockImplementation(() => new Promise(() => {})) + const { result, unmount } = renderReopened(1) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) + + await act(async () => { + void result.current.setStructuredOption('permissionMode', 'full-access') + }) + expect(permission(result.current.optionSnapshot)).toBe('full-access') + await act(async () => { + void result.current.setStructuredOption('permissionMode', 'auto') + }) + expect(permission(result.current.optionSnapshot)).toBe('auto') + expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2) + unmount() + }) + + it("shows the host's mode again when the write is refused", async () => { + optionsAnswering('ask') + vi.mocked(mutate).mockResolvedValue(null) + const { result, unmount } = renderReopened(1) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) + + await act(async () => { + await result.current.setStructuredOption('permissionMode', 'full-access') + }) + await waitFor(() => expect(optionReads()).toBe(2)) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) + unmount() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 812a31525564..bdf995367c55 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -7,6 +7,7 @@ import type { import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, @@ -133,10 +134,21 @@ export function useStructuredAgentSessionOptions(args: { const isCurrent = (): boolean => activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) + // Record-only: shown at once, so the picker stays usable while the write waits behind a start. + const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + if (shownAtOnce) { + updateOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) + : current + ) + } else { + pendingOptionRef.current = id + updateOptionState((current) => ({ ...current, pendingId: id })) + } + let result: AgentSessionOptionResult | null = null try { - const result = await mutate( + result = await mutate( 'agentSession.setOption', 'agentSession.setOption', { key: id, value: encoded } @@ -153,6 +165,11 @@ export function useStructuredAgentSessionOptions(args: { structuredAgentSessionOptionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), committed ) + } + return Boolean(result) + } finally { + // A settled write, or a shown one that failed, reads back what the host now holds. + if (isCurrent() && (result || shownAtOnce)) { void callStructuredAgentSession( target, 'agentSession.options', @@ -169,8 +186,6 @@ export function useStructuredAgentSessionOptions(args: { }) .catch(() => {}) } - return Boolean(result) - } finally { if (isCurrent()) { pendingOptionRef.current = null updateOptionState((current) => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a35be0b2459a..b0c20f8989ba 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2849,7 +2849,7 @@ "composer": { "effort": "Effort", "imageUnsupported": "Image paste is not supported for this agent.", - "permissions": "permissions" + "permissions": "Permissions" }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e0a9b026aea4..c226db4caa53 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18323,7 +18323,7 @@ "attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.", "attachmentUnreadable": "Couldn't read the dropped files.", "tooManyAttachments": "Attach {{value0}} or fewer files at a time.", - "permissions": "permissions", + "permissions": "Permissions", "permissionAsk": "Ask for approval", "permissionAskDescription": "Asks before edits and commands your settings don't allow", "permissionAskCodexDescription": "Works inside the workspace sandbox; asks before going beyond it", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fffee1c9d9c0..7d7712c14731 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18033,7 +18033,7 @@ "attachmentOwnerChanged": "Este espacio de trabajo cambió de host durante la conexión; vuelve a soltar los archivos.", "attachmentUnreadable": "No se pudieron leer los archivos soltados.", "tooManyAttachments": "Adjunta {{value0}} archivos o menos a la vez.", - "permissions": "permisos", + "permissions": "Permisos", "permissionAsk": "Pedir aprobación", "permissionAskDescription": "Pregunta antes de ediciones y comandos que tu configuración no permite", "permissionAskCodexDescription": "Trabaja dentro del entorno aislado del espacio de trabajo; pregunta antes de salir de él", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a36c109015c8..4233b346a407 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18135,7 +18135,7 @@ "attachmentOwnerChanged": "Cet espace de travail a changé d'hôte lors de la connexion : supprimez à nouveau les fichiers.", "attachmentUnreadable": "Impossible de lire les fichiers supprimés.", "tooManyAttachments": "Joignez {{value0}} fichiers maximum à la fois.", - "permissions": "autorisations", + "permissions": "Autorisations", "permissionAsk": "Demander l’approbation", "permissionAskDescription": "Demande avant les modifications et commandes que vos réglages n'autorisent pas", "permissionAskCodexDescription": "Travaille dans le bac à sable de l’espace de travail ; demande avant d’en sortir", diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index b8f3cb688623..4f1fa26175a5 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -191,8 +191,7 @@ export function mergeSettings( } } -/** The host's new-chat permission default, kept only when it is a mode this build knows. The host - * seeds new chats from its own, so web reads and edits that one rather than a local copy. */ +/** The host's new-chat permission default (the host seeds from it), kept only when a known mode. */ export function runtimeChatPermissionSetting( settings: Partial ): Pick { diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index 59829cfea030..2da67f5f7ad4 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' import { AGENT_CHAT_PERMISSION_MODES, - CLAUDE_CHAT_PERMISSION_MODES, - CODEX_CHAT_PERMISSION_MODES, agentChatPermissionModes, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, @@ -13,6 +11,9 @@ import { import { getDefaultSettings } from './constants' import { applyAgentPermissionMode } from './tui-agent-permissions' +const CLAUDE_CHAT_PERMISSION_MODES = agentChatPermissionModes('claude')! +const CODEX_CHAT_PERMISSION_MODES = agentChatPermissionModes('codex')! + describe('agent chat permission modes', () => { it('orders the picker from least to most access', () => { expect(AGENT_CHAT_PERMISSION_MODES).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 922c77000cec..4e39b6c31c52 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -26,16 +26,16 @@ export type AgentChatPermissionModeSupport = { autoNeedsModelSupport?: true } -export const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { +const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { modes: AGENT_CHAT_PERMISSION_MODES, autoNeedsModelSupport: true } -export const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { +const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { modes: ['ask', 'auto', 'full-access'] } -/** Null for an agent whose chats have no picker: it keeps following Agent Permissions. */ +/** The one table of which agents' chats offer which modes; null keeps following Agent Permissions. */ export function agentChatPermissionModes(agent: string): AgentChatPermissionModeSupport | null { return agent === 'claude' ? CLAUDE_CHAT_PERMISSION_MODES @@ -62,8 +62,7 @@ export function chatPermissionModeNeedsModelSupport( ) } -/** What a chat runs and shows: its saved choice, with `auto` on a model not listed for it read as - * Ask. Derived on every read and never written back, so a supporting model gets `auto` again. */ +/** Saved choice, with `auto` on a model not listed for it read as Ask; never written back. */ export function effectiveChatPermissionMode( support: AgentChatPermissionModeSupport, intent: unknown, @@ -91,8 +90,7 @@ export function newChatPermissionMode( return support ? savedChatPermissionMode(support, setting) : null } -/** The new-chat default as loaded. Seeded once from Agent Permissions when absent; once stored it - * no longer follows them. An unknown value from another build asks. */ +/** Seeded once from Agent Permissions when absent, then stored; an unknown value asks. */ export function loadedChatPermissionSetting( saved: unknown, terminal: Partial> diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 4f2552ae4b06..91e01618eda5 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -399,8 +399,7 @@ export type AgentSessionModelOption = { efforts: AgentSessionOptionChoice[] /** Provider catalog fact. Absent means the host could not determine support. */ supportsFastMode?: boolean - /** Whether the model runs the agent's own approval reviewer (Approve for me). Absent is unknown, - * which neither offers nor launches it. */ + /** Whether the model runs Approve for me; absent is unknown, which neither offers nor launches it. */ supportsAutoMode?: boolean /** Service tiers the provider lists for this model besides its standard one, each valued by the * provider's own tier id. Absent means unknown; a client that reads it offers one speed choice @@ -504,8 +503,7 @@ export type AgentSessionOptionsResult = { fastMode?: boolean /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ serviceTier?: string - /** The chat's saved permission mode, which the next start of its agent runs. Absent from a - * host or agent with no permission picker. */ + /** The permission mode a start runs now; absent from a host or agent with no picker. */ permissionMode?: string /** Provider-reported effective routing, distinct from the next-turn preference. */ fastModeState?: AgentSessionFastModeState diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index fee97b1592a4..a80224878239 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -234,8 +234,7 @@ export type GlobalSettings = NativeChatGlobalSettings & { nativeChatShellEnvironmentVariables?: string[] /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ nativeChatSessionOptions?: PersistedNativeChatSessionOptions - /** This host's permission mode for new Claude and Codex chats; each chat keeps its own after. - * Seeded once from Agent Permissions when a profile first loads without it. */ + /** New Claude and Codex chats' permission mode; seeded once from Agent Permissions. */ nativeChatPermissionMode?: AgentChatPermissionMode /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ openInApplications?: OpenInApplication[] diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index ab365c5e6fec..82b0c0efefa6 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -48,8 +48,7 @@ export const STRUCTURED_LAUNCH_SEED_OPTION_IDS = [ 'serviceTier' ] as const -/** What a launch may hold and a create may seed: the saved selection plus the chat's permission - * mode, which seeds from its own setting, so a chat's pick never rewrites the saved selection. */ +/** What a launch may hold and a create may seed; the permission mode never rewrites the selection. */ export const STRUCTURED_LAUNCH_OPTION_IDS = [ ...STRUCTURED_LAUNCH_SEED_OPTION_IDS, AGENT_CHAT_PERMISSION_MODE_OPTION_ID @@ -116,8 +115,7 @@ export function resolveStructuredLaunchSeedOptions( return narrowStructuredLaunchSeedOptions(resolveNativeChatSessionOptionDefaults(persisted, agent)) } -/** Everything a new chat of `agent` on this host starts with: the saved selection and the host's - * permission default, narrowed to what the agent offers. */ +/** A new chat's seed: the saved selection plus the permission default narrowed to the agent. */ export function structuredLaunchSeedOptions( settings: | Pick diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 2e2019d2e6db..5920e68ae173 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -52,8 +52,7 @@ export type StructuredAgentSessionOptionState = { * same list once the host said it has none, usable but naming nothing the host may replace. */ catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null record: NativeChatSessionOptionRecord - /** The chat's saved permission mode as the host or the launch last stated it; null until one - * does, which shows no permission picker. */ + /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null pendingId: string | null } diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index 443d2565aabf..66404d3466de 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -72,16 +72,19 @@ describe('the permission mode picker', () => { ]) }) - it('offers and shows Claude auto only on a model listed as supporting it', () => { - const supported = permission(live('claude', { permissionMode: 'auto' }, true)) - expect(supported).toMatchObject({ kind: { currentValue: 'auto' } }) - for (const autoMode of [false, undefined]) { - const narrowed = permission(live('claude', { permissionMode: 'auto' }, autoMode)) - expect(narrowed).toMatchObject({ kind: { currentValue: 'ask' } }) - expect( - narrowed?.kind.type === 'select' && narrowed.kind.choices.map((choice) => choice.value) - ).toEqual(['ask', 'accept-edits', 'full-access']) + it("shows the host's mode as stated, and offers Claude auto only on a model listed for it", () => { + const offered = (autoMode: boolean | undefined, mode = 'ask') => { + const descriptor = permission(live('claude', { permissionMode: mode }, autoMode)) + return descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) } + expect(offered(true)).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) + expect(offered(false)).toEqual(['ask', 'accept-edits', 'full-access']) + expect(offered(undefined)).toEqual(['ask', 'accept-edits', 'full-access']) + // The host decides what runs; a client catalog that cannot confirm it never relabels it. + expect(permission(live('claude', { permissionMode: 'auto' }, false))).toMatchObject({ + kind: { currentValue: 'auto' } + }) + expect(offered(false, 'auto')).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) }) it("shows a held pick over the host's value, and the host's over the seed", () => { diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 64f5f218a95b..9e6ba58794fe 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -1,10 +1,8 @@ -// The chat's permission mode as a picker descriptor, beside the model's but never locked by it: it -// shows before the agent starts, and only a host that states a mode gets one. +// The chat's permission mode as a picker descriptor: shows the mode the host states, never derives it. import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes, - effectiveChatPermissionMode, offeredChatPermissionModes, type AgentChatPermissionMode } from './agent-chat-permission-mode' @@ -36,7 +34,7 @@ const CODEX_DESCRIPTIONS: Partial> = { 'full-access': 'Never asks; no sandbox' } -/** Whether the model the chat runs is listed as running Approve for me; unknown reads as no. */ +/** Only decides whether the menu offers Approve for me; unknown reads as no. */ function currentModelSupportsAutoMode( catalog: AgentSessionOptionCatalog | null, record: NativeChatSessionOptionRecord @@ -54,18 +52,22 @@ export function structuredAgentSessionPermissionDescriptor(state: { permissionMode: string | null }): SessionOptionDescriptor | null { const support = agentChatPermissionModes(state.record.agent) - if (!support || state.permissionMode === null) { + const current = support?.modes.find((mode) => mode === state.permissionMode) + if (!support || !current) { return null } - const autoSupported = currentModelSupportsAutoMode(state.catalog, state.record) + const offered = offeredChatPermissionModes( + support, + current === 'auto' || currentModelSupportsAutoMode(state.catalog, state.record) + ) return { id: AGENT_CHAT_PERMISSION_MODE_OPTION_ID, label: 'Permissions', category: 'mode', kind: { type: 'select', - currentValue: effectiveChatPermissionMode(support, state.permissionMode, autoSupported), - choices: offeredChatPermissionModes(support, autoSupported).map((value) => ({ + currentValue: current, + choices: offered.map((value) => ({ value, label: MODE_COPY[value].label, description: From 0cbe82117ed4bd02bf0a3fa18b9f47469b464d67 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:58:29 -0700 Subject: [PATCH 4/7] fix(native-chat): describe permission modes without the word sandbox --- .../NativeChatPermissionModePicker.test.tsx | 16 +++++++--------- .../native-chat-permission-mode-labels.ts | 10 +++++----- src/renderer/src/i18n/locales/en.json | 10 +++++----- src/renderer/src/i18n/locales/es.json | 10 +++++----- src/renderer/src/i18n/locales/fr.json | 10 +++++----- src/renderer/src/i18n/locales/ja.json | 10 +++++----- src/renderer/src/i18n/locales/ko.json | 10 +++++----- src/renderer/src/i18n/locales/zh.json | 10 +++++----- ...tured-agent-session-permission-option.test.ts | 8 ++++++-- ...structured-agent-session-permission-option.ts | 6 +++--- 10 files changed, 51 insertions(+), 49 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx index e306de7648a8..71ce69ccee3c 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx @@ -136,9 +136,7 @@ describe('NativeChatPermissionModePicker', () => { within(menu).getByText('Approves file edits and file commands; asks for the rest') ).toBeTruthy() expect(within(menu).getByText('Reviews approval requests for you')).toBeTruthy() - expect( - within(menu).getByText('Skips approval prompts; your Claude rules and sandbox still apply') - ).toBeTruthy() + expect(within(menu).getByText('Never asks before edits or commands')).toBeTruthy() }) it("describes Codex's modes as Codex runs them", () => { @@ -149,19 +147,19 @@ describe('NativeChatPermissionModePicker', () => { ) expect(screen.queryByText('Accept edits')).toBeNull() expect(option('ask').textContent).toContain( - 'Works inside the workspace sandbox; asks before going beyond it' + 'Edits files and runs commands in this workspace; asks before anything else' + ) + expect(option('full-access').textContent).toContain( + 'Never asks; can edit any file and use the network' ) - expect(option('full-access').textContent).toContain('Never asks; no sandbox') }) it('describes both agents in the new-chat setting', () => { render() expect(option('ask').textContent).toContain( - 'Claude asks unless your settings allow it; Codex asks beyond the workspace sandbox' - ) - expect(option('full-access').textContent).toContain( - 'Skips approval prompts; Codex also runs without its sandbox' + "Asks before edits or commands it isn't already allowed to make" ) + expect(option('full-access').textContent).toContain('Never asks before edits or commands') }) it('names the current mode on the pill, by category for assistive tech', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts index fd03acb34cfd..75426c5e4034 100644 --- a/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts +++ b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts @@ -33,13 +33,13 @@ export function nativeChatPermissionModeDescription( if (agent === 'codex') { return translate( 'components.native-chat.composer.permissionAskCodexDescription', - 'Works inside the workspace sandbox; asks before going beyond it' + 'Edits files and runs commands in this workspace; asks before anything else' ) } if (agent !== 'claude') { return translate( 'components.native-chat.composer.permissionAskSharedDescription', - 'Claude asks unless your settings allow it; Codex asks beyond the workspace sandbox' + "Asks before edits or commands it isn't already allowed to make" ) } return translate( @@ -60,18 +60,18 @@ export function nativeChatPermissionModeDescription( if (agent === 'codex') { return translate( 'components.native-chat.composer.permissionFullAccessCodexDescription', - 'Never asks; no sandbox' + 'Never asks; can edit any file and use the network' ) } if (agent !== 'claude') { return translate( 'components.native-chat.composer.permissionFullAccessSharedDescription', - 'Skips approval prompts; Codex also runs without its sandbox' + 'Never asks before edits or commands' ) } return translate( 'components.native-chat.composer.permissionFullAccessDescription', - 'Skips approval prompts; your Claude rules and sandbox still apply' + 'Never asks before edits or commands' ) } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c226db4caa53..2c694224572c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18326,16 +18326,16 @@ "permissions": "Permissions", "permissionAsk": "Ask for approval", "permissionAskDescription": "Asks before edits and commands your settings don't allow", - "permissionAskCodexDescription": "Works inside the workspace sandbox; asks before going beyond it", - "permissionAskSharedDescription": "Claude asks unless your settings allow it; Codex asks beyond the workspace sandbox", + "permissionAskCodexDescription": "Edits files and runs commands in this workspace; asks before anything else", + "permissionAskSharedDescription": "Asks before edits or commands it isn't already allowed to make", "permissionAcceptEdits": "Accept edits", "permissionAcceptEditsDescription": "Approves file edits and file commands; asks for the rest", "permissionAuto": "Approve for me", "permissionAutoDescription": "Reviews approval requests for you", "permissionFullAccess": "Full access", - "permissionFullAccessDescription": "Skips approval prompts; your Claude rules and sandbox still apply", - "permissionFullAccessCodexDescription": "Never asks; no sandbox", - "permissionFullAccessSharedDescription": "Skips approval prompts; Codex also runs without its sandbox", + "permissionFullAccessDescription": "Never asks before edits or commands", + "permissionFullAccessCodexDescription": "Never asks; can edit any file and use the network", + "permissionFullAccessSharedDescription": "Never asks before edits or commands", "pasteFailed": "Paste failed.", "commandAttachmentsUnsupported": "Remove attachments before using a chat-session command." }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7d7712c14731..71b3eb6aeab9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18036,16 +18036,16 @@ "permissions": "Permisos", "permissionAsk": "Pedir aprobación", "permissionAskDescription": "Pregunta antes de ediciones y comandos que tu configuración no permite", - "permissionAskCodexDescription": "Trabaja dentro del entorno aislado del espacio de trabajo; pregunta antes de salir de él", - "permissionAskSharedDescription": "Claude pregunta salvo que tu configuración lo permita; Codex pregunta fuera del sandbox del espacio de trabajo", + "permissionAskCodexDescription": "Edita archivos y ejecuta comandos en este espacio de trabajo; pregunta antes de cualquier otra cosa", + "permissionAskSharedDescription": "Pregunta antes de ediciones o comandos que aún no tiene permitidos", "permissionAcceptEdits": "Aceptar ediciones", "permissionAcceptEditsDescription": "Aprueba ediciones y comandos de archivos; pregunta por lo demás", "permissionAuto": "Aprobar por mí", "permissionAutoDescription": "Revisa las solicitudes de aprobación por ti", "permissionFullAccess": "Acceso total", - "permissionFullAccessDescription": "Omite las solicitudes de aprobación; tus reglas y sandbox de Claude siguen aplicándose", - "permissionFullAccessCodexDescription": "Nunca pregunta; sin sandbox", - "permissionFullAccessSharedDescription": "Omite las solicitudes de aprobación; Codex además se ejecuta sin su sandbox", + "permissionFullAccessDescription": "Nunca pregunta antes de ediciones o comandos", + "permissionFullAccessCodexDescription": "Nunca pregunta; puede editar cualquier archivo y usar la red", + "permissionFullAccessSharedDescription": "Nunca pregunta antes de ediciones o comandos", "pasteFailed": "Error al pegar.", "commandAttachmentsUnsupported": "Quita los archivos adjuntos antes de usar un comando de la sesión de chat." }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 4233b346a407..08a2752bda71 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18138,16 +18138,16 @@ "permissions": "Autorisations", "permissionAsk": "Demander l’approbation", "permissionAskDescription": "Demande avant les modifications et commandes que vos réglages n'autorisent pas", - "permissionAskCodexDescription": "Travaille dans le bac à sable de l’espace de travail ; demande avant d’en sortir", - "permissionAskSharedDescription": "Claude demande sauf si vos réglages l'autorisent ; Codex demande au-delà du bac à sable de l'espace de travail", + "permissionAskCodexDescription": "Modifie des fichiers et exécute des commandes dans cet espace de travail ; demande avant toute autre action", + "permissionAskSharedDescription": "Demande avant les modifications ou commandes qui ne sont pas déjà autorisées", "permissionAcceptEdits": "Accepter les modifications", "permissionAcceptEditsDescription": "Approuve les modifications et commandes de fichiers ; demande pour le reste", "permissionAuto": "Approuver pour moi", "permissionAutoDescription": "Examine les demandes d’approbation pour vous", "permissionFullAccess": "Accès complet", - "permissionFullAccessDescription": "Ignore les demandes d'approbation ; vos règles et le bac à sable de Claude s'appliquent toujours", - "permissionFullAccessCodexDescription": "Ne demande jamais ; sans bac à sable", - "permissionFullAccessSharedDescription": "Ignore les demandes d'approbation ; Codex s'exécute aussi sans son bac à sable", + "permissionFullAccessDescription": "Ne demande jamais avant les modifications ou les commandes", + "permissionFullAccessCodexDescription": "Ne demande jamais ; peut modifier n’importe quel fichier et utiliser le réseau", + "permissionFullAccessSharedDescription": "Ne demande jamais avant les modifications ou les commandes", "pasteFailed": "Échec du collage.", "commandAttachmentsUnsupported": "Retirez les pièces jointes avant d'utiliser une commande de session de chat." }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c629c5a6f414..5a25e4386d38 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18071,16 +18071,16 @@ "permissions": "権限", "permissionAsk": "承認を求める", "permissionAskDescription": "設定で許可されていない編集やコマンドの前に確認します", - "permissionAskCodexDescription": "ワークスペースのサンドボックス内で動作し、その範囲を超える前に確認します", - "permissionAskSharedDescription": "Claude は設定で許可されていなければ確認します。Codex はワークスペースのサンドボックス外で確認します", + "permissionAskCodexDescription": "このワークスペース内でファイルを編集しコマンドを実行します。それ以外の操作の前に確認します", + "permissionAskSharedDescription": "まだ許可されていない編集やコマンドの前に確認します", "permissionAcceptEdits": "編集を許可", "permissionAcceptEditsDescription": "ファイルの編集とファイル操作コマンドを承認し、それ以外は確認します", "permissionAuto": "代わりに承認", "permissionAutoDescription": "承認リクエストを代わりに審査します", "permissionFullAccess": "フルアクセス", - "permissionFullAccessDescription": "承認の確認を省略します。Claude のルールとサンドボックスは引き続き適用されます", - "permissionFullAccessCodexDescription": "確認しません。サンドボックスなし", - "permissionFullAccessSharedDescription": "承認の確認を省略します。Codex はサンドボックスも使いません", + "permissionFullAccessDescription": "編集やコマンドの前に確認しません", + "permissionFullAccessCodexDescription": "確認しません。任意のファイルを編集し、ネットワークを使用できます", + "permissionFullAccessSharedDescription": "編集やコマンドの前に確認しません", "pasteFailed": "貼り付けに失敗しました。", "commandAttachmentsUnsupported": "チャットセッションのコマンドを使う前に添付ファイルを削除してください。" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 046582b64e88..9c8f5097611a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18071,16 +18071,16 @@ "permissions": "권한", "permissionAsk": "승인 요청", "permissionAskDescription": "설정에서 허용하지 않은 편집과 명령 전에 묻습니다", - "permissionAskCodexDescription": "작업 공간 샌드박스 안에서 작업하며, 범위를 벗어나기 전에 묻습니다", - "permissionAskSharedDescription": "Claude는 설정에서 허용하지 않으면 묻고, Codex는 워크스페이스 샌드박스를 벗어날 때 묻습니다", + "permissionAskCodexDescription": "이 작업 공간에서 파일을 편집하고 명령을 실행하며, 그 외 작업 전에는 묻습니다", + "permissionAskSharedDescription": "아직 허용되지 않은 편집이나 명령 전에 묻습니다", "permissionAcceptEdits": "편집 허용", "permissionAcceptEditsDescription": "파일 편집과 파일 명령은 승인하고, 나머지는 묻습니다", "permissionAuto": "대신 승인", "permissionAutoDescription": "승인 요청을 대신 검토합니다", "permissionFullAccess": "전체 액세스", - "permissionFullAccessDescription": "승인 요청을 건너뜁니다. Claude 규칙과 샌드박스는 계속 적용됩니다", - "permissionFullAccessCodexDescription": "묻지 않습니다. 샌드박스 없음", - "permissionFullAccessSharedDescription": "승인 요청을 건너뜁니다. Codex는 샌드박스 없이 실행됩니다", + "permissionFullAccessDescription": "편집이나 명령 전에 묻지 않습니다", + "permissionFullAccessCodexDescription": "묻지 않습니다. 모든 파일을 편집하고 네트워크를 사용할 수 있습니다", + "permissionFullAccessSharedDescription": "편집이나 명령 전에 묻지 않습니다", "pasteFailed": "붙여넣기 실패.", "commandAttachmentsUnsupported": "채팅 세션 명령을 사용하기 전에 첨부 파일을 제거하세요." }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 75aee696b6b3..b30860efc9cb 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18036,16 +18036,16 @@ "permissions": "权限", "permissionAsk": "请求批准", "permissionAskDescription": "在设置未允许的编辑和命令前询问", - "permissionAskCodexDescription": "在工作区沙箱内操作,超出范围前会询问", - "permissionAskSharedDescription": "Claude 会询问,除非设置已允许;Codex 在超出工作区沙盒时询问", + "permissionAskCodexDescription": "在此工作区中编辑文件并运行命令;其他操作前会询问", + "permissionAskSharedDescription": "在执行尚未获准的编辑或命令前询问", "permissionAcceptEdits": "接受编辑", "permissionAcceptEditsDescription": "批准文件编辑和文件命令;其余操作会询问", "permissionAuto": "替我批准", "permissionAutoDescription": "代你审核批准请求", "permissionFullAccess": "完全访问", - "permissionFullAccessDescription": "跳过审批提示;你的 Claude 规则和沙盒仍然适用", - "permissionFullAccessCodexDescription": "从不询问;没有沙盒", - "permissionFullAccessSharedDescription": "跳过审批提示;Codex 还会在没有沙盒的情况下运行", + "permissionFullAccessDescription": "编辑或运行命令前从不询问", + "permissionFullAccessCodexDescription": "从不询问;可以编辑任何文件并使用网络", + "permissionFullAccessSharedDescription": "编辑或运行命令前从不询问", "pasteFailed": "粘贴失败。", "commandAttachmentsUnsupported": "使用聊天会话命令前,请先移除附件。" }, diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index 66404d3466de..aea17e09fc74 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -65,10 +65,14 @@ describe('the permission mode picker', () => { { value: 'ask', label: 'Ask for approval', - description: 'Works inside the workspace sandbox; asks before going beyond it' + description: 'Edits files and runs commands in this workspace; asks before anything else' }, { value: 'auto', label: 'Approve for me', description: 'Reviews approval requests for you' }, - { value: 'full-access', label: 'Full access', description: 'Never asks; no sandbox' } + { + value: 'full-access', + label: 'Full access', + description: 'Never asks; can edit any file and use the network' + } ]) }) diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 9e6ba58794fe..428624f6faf6 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -25,13 +25,13 @@ const MODE_COPY: Readonly> = { - ask: 'Works inside the workspace sandbox; asks before going beyond it', - 'full-access': 'Never asks; no sandbox' + ask: 'Edits files and runs commands in this workspace; asks before anything else', + 'full-access': 'Never asks; can edit any file and use the network' } /** Only decides whether the menu offers Approve for me; unknown reads as no. */ From 21bda62a8ccc62106217d4fc29350b6f872557a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:18:21 -0700 Subject: [PATCH 5/7] fix(native-chat): one rule decides whether a Claude chat offers and runs Approve for me The menu and the host now call the same function: the chat's listed model row, or the row the CLI default resolves to when the chat names none. The model-catalog probe now marks that default row, as a live listing already does, so a new chat can offer Approve for me before its first message. --- .../claude/claude-model-catalog-probe.test.ts | 26 ++++++++++ src/main/claude/claude-model-catalog-probe.ts | 9 +++- ...uctured-agent-session-launch-permission.ts | 15 +++--- ...tured-agent-session-outgrown-child.test.ts | 50 +++++++++++++------ src/shared/agent-chat-permission-mode.test.ts | 20 ++++++++ src/shared/agent-chat-permission-mode.ts | 14 ++++++ src/shared/claude-model-list-probe.test.ts | 23 ++++++++- src/shared/claude-model-list-probe.ts | 18 ++++++- ...ed-agent-session-permission-option.test.ts | 34 +++++++++++++ ...uctured-agent-session-permission-option.ts | 14 +++--- 10 files changed, 187 insertions(+), 36 deletions(-) diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 065d7657455c..5a12ff794ba8 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -101,6 +101,32 @@ describe('claude model catalog probe', () => { } ) + // The rows `claude` 2.1.280 lists: what a chat naming no model runs, and its auto support. + it('marks the row the CLI default resolves to and keeps each row auto support', async () => { + const listing = `${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { + models: [ + { value: 'default', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, + { value: 'opus[1m]', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', supportsAutoMode: null } + ] + } + } + })}\n` + const probe = createClaudeModelCatalogProbe({ ...probeDeps(), runListing: async () => listing }) + const { models } = await probe(HOME('/homes/account-a')) + expect( + models.map(({ id, isDefault, supportsAutoMode }) => ({ id, isDefault, supportsAutoMode })) + ).toEqual([ + { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, + { id: 'haiku', isDefault: false, supportsAutoMode: undefined } + ]) + }) + it('pins no CLAUDE_CONFIG_DIR for the CLI default home, exactly as a session spawn does', async () => { const envs: (NodeJS.ProcessEnv | undefined)[] = [] const probe = createClaudeModelCatalogProbe({ diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index f464187ff5df..1e3b84782ec1 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -1,6 +1,7 @@ import { CLAUDE_MODEL_LIST_ARGS, - CLAUDE_MODEL_LIST_STDIN + CLAUDE_MODEL_LIST_STDIN, + parseClaudeModelList } from '../../shared/claude-model-list-probe' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { parseClaudeModels } from '../../shared/commit-message-model-parsers' @@ -58,12 +59,16 @@ export function createClaudeModelCatalogProbe( if (listed.length === 0) { throw new Error('claude listed no models') } + // Only the catalog marks the CLI default; the commit-message parse keeps its own default. + const runsDefault = new Set( + parseClaudeModelList(stdout).flatMap((model) => (model.isDefault ? [model.id] : [])) + ) return { models: listed.map((model) => ({ id: model.id, label: model.label, ...(model.description ? { description: model.description } : {}), - isDefault: model.isDefault === true, + isDefault: runsDefault.has(model.id), // No defaultEffort: the parser's thinking default is the commit-message generator's // choice, not the effort Claude runs; the store keeps the one a live child reported. efforts: (model.thinkingLevels ?? []).map((level) => ({ 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 80787e3b5803..8494da053e44 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 @@ -4,13 +4,14 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes, + chatModelSupportsAutoMode, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, type AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -/** Saved catalog only, so a start never waits on a listing; a model it cannot name gets no `auto`. */ +/** Saved catalog only, so a start never waits on a listing. */ async function listedAutoModeSupport( catalog: StructuredAgentSessionHostDeps['modelCatalog'], record: AgentSessionRecord @@ -18,14 +19,12 @@ async function listedAutoModeSupport( const listing = await catalog ?.read({ agent: record.provider, sessionId: record.sessionId, savedOnly: true }) .catch(() => null) - if (!listing || listing.origin === 'unknown') { - return false - } - const model = record.options?.model - const row = listing.models.find((entry) => - model === undefined || model === 'default' ? entry.isDefault : entry.id === model + return ( + listing !== null && + listing !== undefined && + listing.origin !== 'unknown' && + chatModelSupportsAutoMode(listing.models, record.options?.model) ) - return row?.supportsAutoMode === true } /** Null for an agent whose chats offer no modes, or one this runtime did not register. */ 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 7b0545ae5696..470e740a043c 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 @@ -304,26 +304,30 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { // The check reads the same mode a start launches with, so each start ends the restarts. describe('against the mode a start launches with', () => { - const rows = [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [] }] - const catalog = (supportsAutoMode?: boolean) => ({ + // The saved catalog the probe keeps from `claude` 2.1.280: the default row folded into the + // row it resolves to, every row but haiku listed as running auto. + const listed = [ + { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, + { id: 'sonnet', isDefault: false, supportsAutoMode: true }, + { id: 'haiku', isDefault: false } + ] + const catalog = (rows: typeof listed) => ({ read: vi.fn(async () => ({ origin: 'probe' as const, fetchedAt: 1, - models: rows.map((row) => - supportsAutoMode === undefined ? row : { ...row, supportsAutoMode } - ) + models: rows.map((row) => ({ ...row, label: row.id, efforts: [] })) })), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => undefined), stop: vi.fn(), providerStarted: vi.fn() }) - const launch = (options: Record, supportsAutoMode?: boolean) => { + const launch = (options: Record, rows = listed) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent and options. const record = { sessionId: 'session', provider: 'claude', options } as AgentSessionRecord return () => structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog: catalog(supportsAutoMode) }, + { agents: claudeAndCodexDeclared(), modelCatalog: catalog(rows) }, record ) } @@ -335,8 +339,11 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { .stopAgent.mock.calls.length > 0 it('restarts Full access or Accept edits into Ask when auto is not confirmed', async () => { - for (const supportsAutoMode of [undefined, false]) { - const autoPick = launch({ model: 'opus', permissionMode: 'auto' }, supportsAutoMode) + 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) @@ -344,20 +351,31 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { } }) - it('never restarts a child started from an alias or a resolved model id', async () => { + it('never restarts a child it just started, from an alias or a resolved id', async () => { const records: Record[] = [ - { model: 'opus', permissionMode: 'auto' }, - { model: 'claude-opus-4-5', permissionMode: 'auto' }, + { permissionMode: 'auto' }, { model: 'default', permissionMode: 'auto' }, - { permissionMode: 'auto' } + { model: 'opus[1m]', permissionMode: 'auto' }, + { model: 'sonnet', permissionMode: 'auto' }, + { model: 'haiku', permissionMode: 'auto' }, + { model: 'claude-opus-5-5[1m]', permissionMode: 'auto' } ] - for (const options of records) { - const current = launch(options, true) + 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(options.model === 'claude-opus-4-5' ? 'ask' : 'auto') + expect(launched).toBe(runsAuto[index] ? 'auto' : 'ask') expect(await stoppedAfterLaunch(launched!, current)).toBe(false) } }) + + // A resolved id launches Ask; the start reports the listed alias, so one restart reaches auto. + 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 () => { diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index 2da67f5f7ad4..be949d7a5305 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_CHAT_PERMISSION_MODES, agentChatPermissionModes, + chatModelSupportsAutoMode, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, loadedChatPermissionSetting, @@ -113,4 +114,23 @@ describe('the new-chat setting as loaded', () => { it.each(['future', null, 42, 'bypass'])('reads an unknown saved %s as Ask', (value) => { expect(loadedChatPermissionSetting(value, getDefaultSettings('/tmp'))).toBe('ask') }) + + // The catalog shape `claude` 2.1.280 lists: the default row is folded into the row it resolves to. + it('reads auto support from the chat model row, or the CLI default when it names none', () => { + const models = [ + { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, + { id: 'sonnet', isDefault: false, supportsAutoMode: true }, + { id: 'haiku', isDefault: false } + ] + for (const model of [undefined, null, 'default', 'opus[1m]', 'sonnet']) { + expect(chatModelSupportsAutoMode(models, model)).toBe(true) + } + for (const model of ['haiku', 'claude-opus-5-5[1m]', 'retired-model']) { + expect(chatModelSupportsAutoMode(models, model)).toBe(false) + } + // No row the CLI runs by default: unknown, so no auto. + expect(chatModelSupportsAutoMode([{ id: 'opus[1m]', supportsAutoMode: true }], undefined)).toBe( + false + ) + }) }) diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 4e39b6c31c52..478b3019c886 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -52,6 +52,20 @@ export function savedChatPermissionMode( return support.modes.find((mode) => mode === intent) ?? 'ask' } +/** The one rule for whether the chat's model runs `auto`: its listed row, or the listed CLI default + * when it names none. The host launches with it and the menu offers with it. */ +export function chatModelSupportsAutoMode( + models: readonly { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[], + model: string | null | undefined +): boolean { + const row = models.find((entry) => + model === undefined || model === null || model === 'default' + ? entry.isDefault === true + : entry.id === model + ) + return row?.supportsAutoMode === true +} + /** Whether the mode a chat runs depends on its model's listed `auto` support. */ export function chatPermissionModeNeedsModelSupport( support: AgentChatPermissionModeSupport, diff --git a/src/shared/claude-model-list-probe.test.ts b/src/shared/claude-model-list-probe.test.ts index 056b41e0a98b..35dc1132cb1d 100644 --- a/src/shared/claude-model-list-probe.test.ts +++ b/src/shared/claude-model-list-probe.test.ts @@ -58,9 +58,30 @@ describe('parseClaudeModelList', () => { label: 'Opus (1M context)', description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], - supportsFastMode: true + supportsFastMode: true, + isDefault: true }) expect(parsed[2]).toMatchObject({ effortLevels: [], supportsFastMode: false }) + expect(parsed.filter((model) => model.isDefault).map(({ id }) => id)).toEqual(['opus[1m]']) + }) + + // `claude` 2.1.280: the default row and the row it resolves to carry the same flag; haiku none. + it('keeps auto support per row and marks the row the default resolves to', () => { + const parsed = parseClaudeModelList( + `${controlResponseLine([ + { value: 'default', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, + { value: 'opus[1m]', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, + { value: 'sonnet', resolvedModel: 'claude-sonnet-5', supportsAutoMode: true }, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', supportsAutoMode: null } + ])}\n` + ) + expect( + parsed.map(({ id, isDefault, supportsAutoMode }) => ({ id, isDefault, supportsAutoMode })) + ).toEqual([ + { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, + { id: 'sonnet', isDefault: undefined, supportsAutoMode: true }, + { id: 'haiku', isDefault: undefined, supportsAutoMode: undefined } + ]) }) it('skips init noise, CRLF endings, and duplicate values', () => { diff --git a/src/shared/claude-model-list-probe.ts b/src/shared/claude-model-list-probe.ts index 096dc2ce6011..113d69aa8595 100644 --- a/src/shared/claude-model-list-probe.ts +++ b/src/shared/claude-model-list-probe.ts @@ -33,6 +33,8 @@ export type ClaudeListedModel = { supportsFastMode: boolean /** Absent when the CLI predates the field: unknown, never unsupported. */ supportsAutoMode?: boolean + /** Runs what the CLI's `default` row resolves to: what a chat that names no model runs. */ + isDefault?: true } const CLAUDE_MODEL_LIST_JSON_LIMITS = { @@ -88,6 +90,16 @@ function toListedModel(value: unknown): ClaudeListedModel | null { } } +function valueOf(entry: unknown): unknown { + return entry && typeof entry === 'object' && 'value' in entry ? entry.value : undefined +} + +function resolvedModelOf(entry: unknown): string | null { + const resolved = + entry && typeof entry === 'object' && 'resolvedModel' in entry ? entry.resolvedModel : undefined + return typeof resolved === 'string' && resolved.trim() ? resolved : null +} + export function parseClaudeModelList(stdout: string): ClaudeListedModel[] { for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim() @@ -110,15 +122,17 @@ export function parseClaudeModelList(stdout: string): ClaudeListedModel[] { } const seen = new Set() const listed: ClaudeListedModel[] = [] + const defaultRuns = resolvedModelOf(models.find((entry) => valueOf(entry) === 'default')) for (const entry of models) { const model = toListedModel(entry) // Why: the `default` row mirrors whichever entry it currently resolves - // to; Orca's pickers manage their own default selection. + // to; that entry is marked instead. if (!model || model.id === 'default' || seen.has(model.id)) { continue } seen.add(model.id) - listed.push(model) + const runsDefault = defaultRuns !== null && resolvedModelOf(entry) === defaultRuns + listed.push(runsDefault ? { ...model, isDefault: true } : model) } if (listed.length > 0) { return listed diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index aea17e09fc74..93f981857db0 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionOptionsResult } from './agent-session-wire' import { sortNativeChatSessionOptions } from './native-chat-session-option-snapshot' import { + applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, commitStructuredAgentSessionOption, commitStructuredAgentSessionOptionValues, @@ -91,6 +92,39 @@ describe('the permission mode picker', () => { expect(offered(false, 'auto')).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) }) + // A new chat names no model: the host's catalog row for the CLI default decides, as the host's does. + it('offers Claude auto to a new chat whose CLI default the host lists as running it', () => { + const seed = structuredAgentSessionSeedCatalog('claude') + const offered = (rows: { id: string; isDefault: boolean; supportsAutoMode?: boolean }[]) => { + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', seed), + seed, + { + origin: 'probe', + fetchedAt: 1, + models: rows.map((row) => ({ ...row, label: row.id, efforts: [] })) + }, + { newLaunch: true } + ) + const descriptor = permission( + structuredAgentSessionOptionView(state, { permissionMode: 'ask' }, {}) + ) + return descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) + } + expect( + offered([ + { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, + { id: 'haiku', isDefault: false } + ]) + ).toContain('auto') + expect( + offered([ + { id: 'opus[1m]', isDefault: false, supportsAutoMode: true }, + { id: 'haiku', isDefault: true } + ]) + ).not.toContain('auto') + }) + it("shows a held pick over the host's value, and the host's over the seed", () => { const state = live('codex', { permissionMode: 'auto' }) const seed = { permissionMode: 'ask' } diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 428624f6faf6..9bf74de0a2d5 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -3,11 +3,11 @@ import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes, + chatModelSupportsAutoMode, offeredChatPermissionModes, type AgentChatPermissionMode } from './agent-chat-permission-mode' import type { AgentSessionOptionCatalog } from './agent-session-option-catalog' -import { resolveEffectiveNativeChatModelId } from './native-chat-session-option-snapshot' import type { NativeChatSessionOptionRecord } from './native-chat-session-option-state' import type { SessionOptionDescriptor } from './native-chat-session-options' @@ -34,16 +34,16 @@ const CODEX_DESCRIPTIONS: Partial> = { 'full-access': 'Never asks; can edit any file and use the network' } -/** Only decides whether the menu offers Approve for me; unknown reads as no. */ +/** Only decides whether the menu offers Approve for me, by the host's own rule. */ function currentModelSupportsAutoMode( catalog: AgentSessionOptionCatalog | null, record: NativeChatSessionOptionRecord ): boolean { - if (!catalog) { - return false - } - const modelId = resolveEffectiveNativeChatModelId(catalog, catalog.models, record) - return catalog.models.find((model) => model.id === modelId)?.supportsAutoMode === true + const model = record.model?.value + return ( + catalog !== null && + chatModelSupportsAutoMode(catalog.models, typeof model === 'string' ? model : undefined) + ) } export function structuredAgentSessionPermissionDescriptor(state: { From b3d54e84364b927c80dcc76af62a750170ae0648 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:44:01 -0700 Subject: [PATCH 6/7] fix(native-chat): re-read a new chat's options when its account listing lands A chat opened before its account's first model listing read its options at rest from the agent's built-in list, which names no Approve for me support. The client takes that answer as the session's own list, so the host list that landed a moment later was ignored until the first turn. When a waited listing lands, the chat now reads its options again, on desktop and phone. --- .../use-mobile-host-model-catalog-upgrade.ts | 11 +- ...ured-agent-options.permission-mode.test.ts | 73 +++++- .../use-mobile-structured-agent-options.ts | 17 +- .../use-host-model-catalog-upgrade.ts | 15 +- ...e-structured-agent-session-option-state.ts | 4 + .../use-structured-agent-session-options.ts | 4 +- ...claude-auto-mode-host-catalog.unit.test.ts | 214 ++++++++++++++++++ 7 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/claude-auto-mode-host-catalog.unit.test.ts diff --git a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts index e4581c8f311c..77798c5955d1 100644 --- a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -32,6 +32,8 @@ export function useMobileHostModelCatalogUpgrade(args: { fence: number | null optionCatalog: AgentSessionOptionCatalog | null activeOptionRecordRef: MutableRefObject + /** A listing landed after the chat's options were read, which answered without it. */ + onListingLanded?: () => void updateOptionState: ( update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState ) => void @@ -43,6 +45,7 @@ export function useMobileHostModelCatalogUpgrade(args: { enabled, fence, newLaunch, + onListingLanded, optionCatalog, sessionId, updateOptionState, @@ -89,7 +92,12 @@ export function useMobileHostModelCatalogUpgrade(args: { if (catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale) { // Usable on the built-in list while the listing runs; it lands in place. apply(catalog) - return read(true).then(apply) + return read(true).then((listed) => { + apply(listed) + if (!stale && listed.origin !== 'unknown') { + onListingLanded?.() + } + }) } apply(catalog) return undefined @@ -105,6 +113,7 @@ export function useMobileHostModelCatalogUpgrade(args: { enabled, fence, newLaunch, + onListingLanded, optionCatalog, sessionId, updateOptionState, diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index fafc3c72a30e..4bb01e3c1635 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -45,7 +45,7 @@ function optionsClient(...modes: (string | undefined)[]) { } type Controller = ReturnType -type ProbeProps = { client: RpcClient; fence: number; optionStamp: string } +type ProbeProps = { client: RpcClient; fence: number; optionStamp: string; agent?: string } const mutate: StructuredAgentSessionMutate = vi.fn() @@ -53,7 +53,7 @@ async function mountOptions(props: ProbeProps) { const rendered: { current: Controller | null } = { current: null } function Probe(probe: ProbeProps): null { rendered.current = useMobileStructuredAgentOptions({ - agent: 'codex', + agent: probe.agent ?? 'codex', client: probe.client, sessionId: 'session-1', enabled: true, @@ -74,6 +74,12 @@ async function mountOptions(props: ProbeProps) { }) await settle() return { + offered: () => { + const entry = rendered.current?.optionSnapshot.find( + (descriptor) => descriptor.id === 'permissionMode' + ) + return entry?.kind.type === 'select' ? entry.kind.choices.map((choice) => choice.value) : [] + }, permission: () => { const entry = rendered.current?.optionSnapshot.find( (descriptor) => descriptor.id === 'permissionMode' @@ -142,3 +148,66 @@ describe('the permission mode on the phone', () => { await options.unmount() }) }) + +/** A host whose account listing lands only after the chat's first options read. */ +function listingLandsLaterClient() { + let landed = false + let land: () => void = () => {} + const lands = new Promise((resolve) => (land = resolve)) + const rows = [ + { id: 'opus[1m]', label: 'Opus', isDefault: true, efforts: [], supportsAutoMode: true }, + { id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] } + ] + const answer = async (method: string, waitForListing: boolean) => { + if (method === 'agentSession.modelCatalog') { + if (!waitForListing) { + return { origin: 'unknown', listingInProgress: true } + } + await lands + return { origin: 'probe', fetchedAt: 1, models: rows } + } + // At rest, a read before the listing lands answers from the agent's built-in list. + return landed + ? { models: rows, current: { model: 'opus[1m]', permissionMode: 'ask' } } + : { + models: [{ id: 'sonnet', label: 'Sonnet', isDefault: true, efforts: [] }], + current: { permissionMode: 'ask' } + } + } + const client: RpcClient = { + ...optionsClient().client, + sendRequest: async (method: string, params?: unknown) => ({ + id: 'rpc-1', + ok: true as const, + result: await answer( + method, + typeof params === 'object' && params !== null && 'waitForListing' in params + ), + _meta: { runtimeId: 'runtime-1' } + }) + } + return { + client, + land: () => { + landed = true + land() + } + } +} + +describe('Approve for me on a phone chat opened before its account was listed', () => { + it('is offered once the listing lands', async () => { + const host = listingLandsLaterClient() + const options = await mountOptions({ + client: host.client, + fence: 1, + optionStamp: '', + agent: 'claude' + }) + expect(options.offered()).toEqual(['ask', 'accept-edits', 'full-access']) + host.land() + await options.rerender({}) + expect(options.offered()).toContain('auto') + await options.unmount() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index e07e5ec43b69..8d730483ab83 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -110,6 +110,9 @@ export function useMobileStructuredAgentOptions(args: { () => (sessionId ? mobileCreatedStructuredSession(sessionId) : undefined), [sessionId] ) + // An options answer made before the account's listing landed listed none of it. + const [listingsLanded, setListingsLanded] = useState(0) + const onListingLanded = useCallback(() => setListingsLanded((count) => count + 1), []) useMobileHostModelCatalogUpgrade({ agent, client, @@ -120,7 +123,8 @@ export function useMobileStructuredAgentOptions(args: { ...(createdHere ? { worktree: createdHere.worktree } : {}), optionCatalog, activeOptionRecordRef, - updateOptionState + updateOptionState, + onListingLanded }) useEffect(() => { @@ -146,7 +150,16 @@ export function useMobileStructuredAgentOptions(args: { return () => { stale = true } - }, [client, enabled, optionCatalog, optionStamp, sessionId, fence, updateOptionState]) + }, [ + client, + enabled, + optionCatalog, + optionStamp, + listingsLanded, + sessionId, + fence, + updateOptionState + ]) const optionSnapshot = useMemo( () => structuredAgentSessionOptionSnapshot(optionState), diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index b2305ec7eca7..3d75035aefdf 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -59,6 +59,8 @@ export function useHostModelCatalogUpgrade(args: { reportedUnpickedModel?: boolean /** The host runs the chat's agent: a reason its start gave ends with it. */ providerRunning?: boolean + /** A listing landed after the chat's options were read, which answered without it. */ + onListingLanded?: () => void activeOptionRecordRef: MutableRefObject updateOptionState: ( update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState @@ -70,6 +72,7 @@ export function useHostModelCatalogUpgrade(args: { enabled, fence, newLaunch, + onListingLanded, optionCatalog, sessionId, target, @@ -137,7 +140,16 @@ export function useHostModelCatalogUpgrade(args: { } let leave: (() => void) | null = null const waitForListing = (): void => { - leave = joinHostModelListingWait(waitKey, () => read(true), apply) + leave = joinHostModelListingWait( + waitKey, + () => read(true), + (catalog) => { + apply(catalog) + if (catalog && catalog.origin !== 'unknown') { + onListingLanded?.() + } + } + ) } if (isHostModelListingWaitInFlight(waitKey)) { // The host already answered that its listing is running: the built-in list stands meanwhile. @@ -175,6 +187,7 @@ export function useHostModelCatalogUpgrade(args: { enabled, fence, newLaunch, + onListingLanded, optionCatalog, reportedUnpickedModel, rereads, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 7a14963e7282..24a06ac22f81 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -193,8 +193,12 @@ export function useStructuredAgentSessionOptionState(args: { } }, [contextRefresh]) + // An options answer made before the account's listing landed listed none of it. + const rereadOptions = useCallback(() => optionsReadRef.current?.run(), []) + return { conversationSupport, + rereadOptions, optionState, optionStateRef, activeOptionRecordRef, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index bdf995367c55..f7d7b91bf097 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -75,6 +75,7 @@ export function useStructuredAgentSessionOptions(args: { pendingOptionRef, optionMutationGeneration, updateOptionState, + rereadOptions, conversationSupport } = useStructuredAgentSessionOptionState({ agent, @@ -113,7 +114,8 @@ export function useStructuredAgentSessionOptions(args: { optionState.catalogSource === 'live', ...(args.providerRunning ? { providerRunning: true } : {}), activeOptionRecordRef, - updateOptionState + updateOptionState, + onListingLanded: rereadOptions }) // What a settled pick must remember so the next launch starts where the user left off. diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts new file mode 100644 index 000000000000..e01479b2e150 --- /dev/null +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -0,0 +1,214 @@ +// @vitest-environment happy-dom +// A new Claude chat offers Approve for me from the host's real catalog: the background listing, its +// saved file, the host's reads and the renderer's hook, with only the CLI's answer stood in. + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ rpc: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.rpc +})) +vi.mock( + '../../src/renderer/src/components/native-chat/native-chat-session-option-settings-write', + () => ({ + enqueueSessionOptionSettingsWrite: vi.fn() + }) +) +vi.mock('@/lib/structured-agent-session-launch-options', () => ({ + holdStructuredAgentSessionLaunchOption: vi.fn(), + getStructuredAgentSessionLaunchSelection: () => null +})) + +import { agentSessionRecordFixture } from '../../src/main/native-chat/agent-session-record-test-fixture' +import type { SessionOptionDescriptor } from '../../src/shared/native-chat-session-options' +import { createClaudeModelCatalogProbe } from '../../src/main/claude/claude-model-catalog-probe' +import { createAgentModelCatalogFilePersistence } from '../../src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence' +import { createAgentModelCatalogService } from '../../src/main/native-chat/agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../../src/main/native-chat/agent-model-catalog/agent-model-catalog-store' +import { workspaceMayOverrideDefaultModel } from '../../src/main/native-chat/agent-model-catalog/agent-project-model-override' +import { claudeAndCodexDeclared } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import type { StructuredAgentSessionMutationContext } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-options-read' +import { resolveStructuredAgentCommand } from '../../src/main/native-chat/structured-agent-command-resolution' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { useStructuredAgentSessionOptions } from '../../src/renderer/src/components/native-chat/use-structured-agent-session-options' + +const SESSION = 'claude-session-1' +const LOCAL_TARGET = { kind: 'local' } as const +const LAUNCH = { + kind: 'new', + heldOptions: {}, + worktree: 'id:workspace-1', + seedOptions: { permissionMode: 'ask' } +} as const +const mutate = vi.fn() + +// What `claude` 2.1.280 answers `list_models` with, reduced to the fields the catalog reads. +const LISTING = `${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { + models: [ + { value: 'default', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, + { + value: 'opus[1m]', + displayName: 'Opus (1M context)', + resolvedModel: 'claude-opus-5-5[1m]', + supportsAutoMode: true + }, + { + value: 'sonnet', + displayName: 'Sonnet', + resolvedModel: 'claude-sonnet-5', + supportsAutoMode: true + }, + { + value: 'haiku', + displayName: 'Haiku', + resolvedModel: 'claude-haiku-4-5-20251001', + supportsAutoMode: null + } + ] + } + } +})}\n` + +let dir: string +let release: () => void + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-host-auto-mode-')) + mocks.rpc.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** The host's catalog over its saved file, listing through the real Claude probe. */ +async function hostCatalog(listingLands: Promise) { + const store = new AgentModelCatalogStore() + const persistence = createAgentModelCatalogFilePersistence(join(dir, 'state')) + await store.attachPersistence(persistence) + const record = agentSessionRecordFixture({ + sessionId: SESSION, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(dir, 'claude-home') }, + options: { permissionMode: 'ask' } + }) + const modelCatalog = createAgentModelCatalogService({ + store, + getRecord: () => record, + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + workspaceMayOverrideDefaultModel, + probes: { + claude: createClaudeModelCatalogProbe({ + resolveCommand: () => + resolveStructuredAgentCommand('claude', { + agentCmdOverrides: { claude: `"${process.execPath}"` } + }), + resolveEnv: () => ({}), + resolveInheritedEnv: async () => ({ PATH: '/resolved/bin' }), + resolveAuthPolicy: () => ({ account: 'system' }), + runListing: async () => { + await listingLands + return LISTING + } + }) + } + }) + const resting = { + child: null, + params: { provider: 'claude' }, + journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { + adapter: {}, + agents: claudeAndCodexDeclared(), + store: { getRecord: () => record }, + modelCatalog + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + // As the host's RPC methods answer them: the catalog for the chat's folder, and its options. + mocks.rpc.mockImplementation( + (_target: unknown, method: string, params: { waitForListing?: boolean }) => + method === 'agentSession.modelCatalog' + ? modelCatalog.read({ + agent: 'claude', + sessionId: SESSION, + workspacePath: join(dir, 'project'), + ...(params.waitForListing ? { waitForListing: true } : {}) + }) + : method === 'agentSession.options' + ? readStructuredAgentSessionOptions(context, SESSION) + : new Promise(() => {}) + ) + return { persistence } +} + +function offered(snapshot: readonly SessionOptionDescriptor[]): string[] { + const entry = snapshot.find((descriptor) => descriptor.id === 'permissionMode') + return entry?.kind.type === 'select' ? entry.kind.choices.map((choice) => choice.value) : [] +} + +function openNewChat() { + return renderHook(() => + useStructuredAgentSessionOptions({ + agent: 'claude', + sessionId: SESSION, + target: LOCAL_TARGET, + transportEnabled: true, + isVisible: true, + providerVisible: true, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch: LAUNCH + }) + ) +} + +const optionReads = () => + mocks.rpc.mock.calls.filter(([, method]) => method === 'agentSession.options').length + +describe('Approve for me on a new Claude chat', () => { + it('is offered once the background listing lands after the chat opened', async () => { + const lands = new Promise((resolve) => (release = resolve)) + await hostCatalog(lands) + const { result, unmount } = openNewChat() + // The chat opened before its account was ever listed: the first answers come without a list. + await waitFor(() => expect(optionReads()).toBe(1)) + await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('ask')) + expect(offered(result.current.optionSnapshot)).not.toContain('auto') + + release() + await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('auto')) + unmount() + }) + + it('is offered from the catalog the background listing saved before the chat opened', async () => { + const first = await hostCatalog(Promise.resolve()) + await mocks.rpc(undefined, 'agentSession.modelCatalog', { waitForListing: true }) + await first.persistence.flush() + // A restarted host loads the saved file: the chat opens warm. + await hostCatalog(new Promise(() => {})) + const { result, unmount } = openNewChat() + await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('auto')) + unmount() + }) +}) From 3e14c2e7c30264e7339478e769cb573d8ee6cad1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:07:32 -0700 Subject: [PATCH 7/7] test(native-chat): run the permission-mode SQLite tests in the Node runtime project --- config/scripts/vitest-node-runtime-files.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index be25f78cc489..a378b17fb7c3 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -69,6 +69,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts', 'src/main/native-chat/structured-chat-naming-command-first.test.ts', 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts',