diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 9c085bc44b56..fd0f2a4a6886 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -72,6 +72,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', diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index 559528ca93a3..5428cbe18594 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -290,4 +290,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 4907da577c2c..71ab3d5dc013 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,14 +55,19 @@ 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) { + if (!model && !permission) { return null } - const disabled = isWorking || pendingId !== null || sendInFlight const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId) - const modelView = activeDescriptor?.id === model.id - const modelLabel = mobileModelPillLabel(model) + 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 = 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) @@ -64,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 => { @@ -77,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() @@ -91,26 +106,46 @@ 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} + {model ? ( + + ) : null} {activeDescriptor ? ( [styles.sheetNav, pressed && styles.pressed]} - onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)} + onPress={modelView || permissionView ? closePicker : openPicker} hitSlop={8} > - {modelView ? ( + {modelView || permissionView ? ( ) : ( @@ -136,6 +171,7 @@ export function MobileNativeChatSessionOptionPickers({ grouped onSetOption={(value) => applyOption(activeDescriptor, value)} onInvokeAction={() => invokeAction(activeDescriptor)} + {...(permissionView ? { choiceLabelColor: permissionChoiceColor } : {})} /> {modelView && options.length > 0 ? ( @@ -161,9 +197,12 @@ export function MobileNativeChatSessionOptionPickers({ const styles = StyleSheet.create({ // The composer row's only shrinkable item: a long label truncates before it pushes Send off. - pillSlot: { + pills: { + flexDirection: 'row', + alignItems: 'center', flexShrink: 1, - minWidth: 0 + minWidth: 0, + 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-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 new file mode 100644 index 000000000000..4bb01e3c1635 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -0,0 +1,213 @@ +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; agent?: string } + +const mutate: StructuredAgentSessionMutate = vi.fn() + +async function mountOptions(props: ProbeProps) { + const rendered: { current: Controller | null } = { current: null } + function Probe(probe: ProbeProps): null { + rendered.current = useMobileStructuredAgentOptions({ + agent: probe.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 { + 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' + ) + return entry?.kind.type === 'select' ? entry.kind.currentValue : null + }, + rerender: async (next: Partial) => { + await act(async () => { + renderer?.update(createElement(Probe, { ...props, ...next })) + }) + await settle() + }, + pick: async (mode: string) => { + await act(async () => { + void rendered.current?.setStructuredOption('permissionMode', mode) + }) + 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 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: '' }) + expect(rpc.reads()).toBe(1) + expect(options.permission()).toBeNull() + 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 09eefd80c36b..63a434f90b1c 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -21,6 +21,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, @@ -53,8 +54,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), @@ -96,10 +99,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 @@ -112,6 +117,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, @@ -122,7 +130,8 @@ export function useMobileStructuredAgentOptions(args: { ...(createdHere ? { worktree: createdHere.worktree } : {}), optionCatalog, activeOptionRecordRef, - updateOptionState + updateOptionState, + onListingLanded }) useEffect(() => { @@ -152,7 +161,16 @@ export function useMobileStructuredAgentOptions(args: { return () => { stale = true } - }, [client, enabled, optionCatalog, sessionId, fence, updateOptionState]) + }, [ + client, + enabled, + optionCatalog, + optionStamp, + listingsLanded, + sessionId, + fence, + updateOptionState + ]) const optionSnapshot = useMemo( () => structuredAgentSessionOptionSnapshot(optionState), @@ -175,18 +193,30 @@ export function useMobileStructuredAgentOptions(args: { } const targetRecord = currentState.record const mutationGeneration = ++optionMutationGeneration.current - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) + const isCurrent = (): boolean => + activeOptionRecordRef.current === targetRecord && + optionMutationGeneration.current === mutationGeneration + // 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 })) + } + // A same-fence accepted write, or a shown one that failed, reads back what the host now holds. + let readsBack = shownAtOnce try { const result = await mutate( 'agentSession.setOption', 'agentSession.setOption', { key: id, value: encoded } ) - if ( - activeOptionRecordRef.current !== targetRecord || - optionMutationGeneration.current !== mutationGeneration - ) { + if (!isCurrent()) { return result.status !== 'rejected' } if (result.status === 'accepted') { @@ -206,27 +236,11 @@ export function useMobileStructuredAgentOptions(args: { picks: structuredAgentSessionOptionPicks(currentState, committed) }) } - 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) - } + readsBack = result.sameFence return true } if (result.status === 'unknown') { + readsBack = false updateOptionState((current) => current.record === targetRecord ? commitStructuredAgentSessionOption(current, id, encoded) @@ -236,10 +250,22 @@ export function useMobileStructuredAgentOptions(args: { } return false } finally { - if ( - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - ) { + if (readsBack && isCurrent()) { + void callAgentSession(client, 'agentSession.options', { + sessionId + }) + .then((refreshed) => { + if (isCurrent()) { + updateOptionState((latest) => + latest.record === targetRecord + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => undefined) + } + if (isCurrent()) { pendingOptionRef.current = null updateOptionState((current) => current.record === targetRecord && current.pendingId === id 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..c1696161e89b --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-option-stamp.ts @@ -0,0 +1,24 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +/** 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 + 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 487011dbdf67..0fd1a9730ea4 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -6,7 +6,6 @@ import { query, type CanUseTool, type Options, - type PermissionMode, type SDKUserMessage, type SpawnedProcess as SdkSpawnedProcess, type SpawnOptions as SdkSpawnOptions @@ -14,6 +13,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' @@ -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,17 @@ 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, + workspaceId: 'workspace-1', + hostId: LOCAL_EXECUTION_HOST_ID, + agent: 'claude', + providerHandle: null + }, + permissionMode + }) } function singleUserTurn(): AsyncIterable { @@ -330,7 +338,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.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 5b85e86266a9..2ef1854ecd6a 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' @@ -57,12 +58,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) => ({ @@ -71,6 +76,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 cf7a868e518a..809a81d70263 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -89,7 +89,11 @@ export async function resolveClaudeAcquisitionLaunch(args: { } : input.identity const launch = await deps - .resolveLaunch({ identity: launchIdentity, spawnToken: input.spawnToken }) + .resolveLaunch({ + identity: launchIdentity, + spawnToken: input.spawnToken, + ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}) + }) .catch((error: unknown) => { throw error instanceof AgentSessionPreSpawnError ? error 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 548c064f41a2..72eb85f677c5 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 d85ce3a70bef..63c6ac8ba126 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -12,7 +12,6 @@ import { CLAUDE_STRUCTURED_BASE_OPTIONS, claudeSessionIdForOrcaSession } 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' @@ -56,8 +55,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 @@ -67,7 +64,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 } : {}), @@ -116,6 +112,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() @@ -136,7 +134,6 @@ describe('claude structured launch resolution', () => { value, undefined, 'system', - undefined, hasTranscript )({ identity: identityAt('old-leaf') }) const expected = claudeSessionIdForOrcaSession(SESSION_ID, boundary.operationId) @@ -153,7 +150,6 @@ describe('claude structured launch resolution', () => { value, undefined, 'system', - undefined, hasTranscript )({ identity: IDENTITY }) expect(retry.providerSessionId).toBe(expected) @@ -161,7 +157,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) @@ -260,7 +255,6 @@ describe('claude structured launch resolution', () => { }), undefined, 'system', - { claude: '' }, hasTranscript )({ identity: identityAt(null) }) @@ -279,15 +273,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({ @@ -298,31 +288,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( @@ -343,7 +332,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) }) @@ -353,7 +342,6 @@ describe('claude structured launch resolution', () => { record({ ...RESUMABLE, launchArgs: ['--model', 'stale'] }), undefined, 'system', - { claude: '' }, async () => true, () => args ) @@ -413,7 +401,6 @@ describe('claude structured launch resolution', () => { record(), undefined, 'system', - { claude: '' }, async () => true, undefined, '/state/agent-session-attachments' @@ -428,7 +415,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 f7d937ccab5a..581b8e1dbcef 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -5,6 +5,7 @@ import type { Options as ClaudeAgentSdkOptions, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +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 type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -66,16 +67,20 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { extraArgs: { 'replay-user-messages': null } } -/** - * Agent Permissions 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. - */ +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> + +/** Full access is the owned CLI flag: the SDK's typed bypass emits a flag older binaries reject. */ 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 = { @@ -104,8 +109,6 @@ export type ClaudeStructuredLaunchPartDeps = { /** 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 @@ -197,9 +200,7 @@ export function claudeStructuredLaunchPart( ...(deps.attachmentDirectory ? [deps.attachmentDirectory] : []), ...(visuals ? [visuals.visuals.folder] : []) ] - const permission = claudeStructuredPermissionOptions( - (await deps.resolvePermissionMode?.()) ?? 'default' - ) + const permission = claudeStructuredPermissionOptions(basis.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 a24a869ec123..40d3f71982e9 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 2dd2849402a8..f5d7b67777b2 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, @@ -16,6 +13,7 @@ import type { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import type { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition' 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,10 +96,7 @@ export type ClaudeLateDispatchOutcome = export type ClaudeStructuredSessionAdapterDeps = { /** The `/` surface of a chat whose Claude is not running. */ atRestCommands?: ClaudeAtRestCommandCatalog - resolveLaunch: (input: { - identity: AgentSessionJournalIdentity - spawnToken: string - }) => Promise + resolveLaunch: (input: StructuredLaunchRequest) => 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-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 53b35f69b97f..a97ece1dcb9c 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -1,4 +1,7 @@ -import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' +import { + createCodexStructuredLaunchResolver, + TEST_SPAWN_TOKEN +} from '../runtime/structured-agent-launch-composition.test-support' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,7 +10,6 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' 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 { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' @@ -55,7 +57,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({ @@ -63,8 +64,7 @@ function resolverFor( resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, - resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), - resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs }) + resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []) }) } @@ -91,7 +91,6 @@ describe('codex structured launch resolution', () => { }), undefined, undefined, - { codex: '' }, () => args ) await expect(resolve({ identity: IDENTITY })).rejects.toThrow(/Arguments/) @@ -165,8 +164,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' + }, env: { CODEX_HOME: '/home/work/.codex' }, envToDelete: [NATIVE_CHAT_VISUALS_DIR_ENV] }) @@ -251,38 +254,37 @@ 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 }) - - expect(launch.args).toEqual(['app-server']) - expect(launch.permissionPolicy).toEqual({ - approvalPolicy: 'never', - sandbox: 'danger-full-access' + 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, + spawnToken: TEST_SPAWN_TOKEN, + permissionMode }) - }) - - 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' - }) + expect(launch.permissionPolicy).toEqual(policy) }) - // 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 }) + // 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, spawnToken: TEST_SPAWN_TOKEN, permissionMode: 'ask' }) - 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 ecf312233fb7..22c30ba8f9fb 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -11,7 +11,7 @@ import { agentSessionProviderHandleChainHead } from '../../shared/agent-session- import { resolveCodexCommand } from '../codex-cli/command' import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' -import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import { @@ -24,9 +24,6 @@ export type CodexStructuredLaunchPartDeps = { /** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */ resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string 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 } export type CodexStructuredInvocationDeps = Pick< @@ -75,7 +72,6 @@ export function codexStructuredLaunchPart( }) const environment = await basis.environment() 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 launch admission refuses anything else. const resumeThreadId = head?.handle.nativeId ?? null @@ -95,7 +91,8 @@ export function codexStructuredLaunchPart( // 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 } : {}), + // States every posture outright: a resume inherits the last one for any field left absent. + permissionPolicy: codexStructuredPermissionPolicy(basis.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 3b0d3b2e1afa..2cbc854e54d1 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -114,7 +114,11 @@ export async function acquireCodexStructuredSession(input: { } acquisitions.assertCurrent(sessionId, attempt) const launch = await deps - .resolveLaunch({ identity: acquireInput.identity, spawnToken: acquireInput.spawnToken }) + .resolveLaunch({ + identity: acquireInput.identity, + spawnToken: acquireInput.spawnToken, + ...(acquireInput.permissionMode ? { permissionMode: acquireInput.permissionMode } : {}) + }) .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index f02826c780f7..8f36128f7141 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -163,7 +163,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].launch.cwd).toBe('/work/repo') expect(codex.connections[0].calls[0]).toEqual({ method: 'thread/start', - params: { cwd: '/work/repo' } + // A chat with no mode asks, stated outright. + params: { + cwd: '/work/repo', + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + approvalsReviewer: 'user' + } }) expect(acquisition.process).toEqual({ hostId: 'host-1', diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 70536db781dc..e0e16a86e555 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -2,8 +2,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session- import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { AgentJournalAnsweredTurnIdentity, - AgentJournalItemIdentity, - AgentSessionJournalIdentity + AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { randomUUID } from 'node:crypto' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' @@ -23,6 +22,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 { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition' import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore @@ -90,10 +90,7 @@ export type CodexStructuredSessionEvent = export type CodexStructuredSessionAdapterDeps = { resolveAccountKind?: (home: string) => AgentSessionAccountKind | undefined - resolveLaunch: (input: { - identity: AgentSessionJournalIdentity - spawnToken: string - }) => Promise + resolveLaunch: (input: StructuredLaunchRequest) => 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 21f71cdaa11e..0508271d1880 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 9be5ae455b30..7b45206e2dfa 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -18,13 +18,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 } @@ -188,7 +189,6 @@ async function acquired( resolveCommand: () => 'codex', resolveLaunchArgs: () => [], resolveRollout: async () => null, - resolvePermissionPolicy: () => MANUAL, prepareVisuals: async () => launch.visuals ?? null }) }) 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-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..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 @@ -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)) ?? undefined, ...(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 c2c655a406e9..b44909d01d7f 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, @@ -176,10 +177,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..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 @@ -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,8 @@ 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 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-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 8f9a97671a07..1b66064df5fd 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..640ea42ed084 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -0,0 +1,110 @@ +// 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' +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.toBe('accept-edits') + expect(claude.read).not.toHaveBeenCalled() + const codex = launchPermission('codex', { permissionMode: 'auto' }) + await expect(codex.result).resolves.toBe('auto') + expect(codex.read).not.toHaveBeenCalled() + }) + + it('asks for a chat saved before modes existed', async () => { + await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') + }) + + it('runs Claude auto on a model the saved catalog lists as supporting it', async () => { + const supported = launchPermission( + 'claude', + { permissionMode: 'auto', model: 'opus' }, + listing([{ id: 'opus', supportsAutoMode: true }]) + ) + await expect(supported.result).resolves.toBe('auto') + 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.toBe('ask') + }) + + it('asks when the catalog cannot confirm the model runs auto', async () => { + for (const answer of [ + { origin: 'unknown' } as const, + listing([{ id: 'opus' }]), + listing([{ id: 'sonnet', supportsAutoMode: true }]), + new Error('catalog unavailable') + ]) { + await expect( + launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result + ).resolves.toBe('ask') + } + }) + + it('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..3e9ac14a40bd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -0,0 +1,50 @@ +// 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, + 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; for the chat's own folder, as its + * options read names its default. */ +async function listedAutoModeSupport( + catalog: StructuredAgentSessionHostDeps['modelCatalog'], + record: AgentSessionRecord +): Promise { + const listing = await catalog + ?.read({ + agent: record.provider, + sessionId: record.sessionId, + savedOnly: true, + inSessionWorkspace: true + }) + .catch(() => null) + return ( + listing !== null && + listing !== undefined && + listing.origin !== 'unknown' && + chatModelSupportsAutoMode(listing.models, record.options?.model) + ) +} + +/** Null for 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 = agentChatPermissionModes(record.provider) + if (!support || !deps.agents.definition(record.provider)) { + return null + } + const intent = record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + 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 3bba13ff774d..6ffbb4b5ba9b 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 @@ -62,12 +62,14 @@ describe('options at rest', () => { expect(result.models).toEqual([]) expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } + // Of these, only Codex's chats state a permission mode. expect( (await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })) .current ).toEqual({ model: 'later-model', - effort: 'off' + effort: 'off', + ...(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 df029f7bd159..475c310350df 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 @@ -13,6 +13,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, + 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' import type { StructuredAgentDefinition } from './structured-agent-definition' @@ -20,6 +24,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' @@ -33,14 +38,29 @@ export const IN_PROCESS_OPTIONS_READER: StructuredAgentSessionOptionsReader = { readsWithoutModel: true } +/** Whether the agent takes this pick: a permission mode it offers, or an option key it names. */ +function acceptsOptionPick( + agents: Pick, + provider: string, + key: string, + value: string +): boolean { + const definition = agents.definition(provider) + return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + ? definition !== null && + agentChatPermissionModes(provider)?.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 key = Object.keys(options).find( + (key) => !acceptsOptionPick(agents, provider, key, options[key] ?? '') + ) return key === undefined ? null : refuse( @@ -134,7 +154,7 @@ 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, record.provider, input.key, input.value)) { return { ok: false, refusal: refuse( @@ -204,13 +224,19 @@ 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 } : {} + // 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 } } : {}), 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 59% 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..470e740a043c 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,13 @@ 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 { 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, @@ -185,7 +190,7 @@ describe('structuredAgentSessionChildReportedSignedOut', () => { }) }) -describe('retireSignedOutStructuredAgentSessionChild', () => { +describe('retireOutgrownStructuredAgentSessionChild', () => { const liveChildWork: AgentChildWorkView = { id: 'subagent-1', kind: 'agent', @@ -205,11 +210,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 +268,132 @@ 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: 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 () => { + 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, 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, 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', () => { + // 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) => ({ ...row, label: row.id, efforts: [] })) + })), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => undefined), + stop: vi.fn(), + providerStarted: vi.fn() + }) + 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(rows) }, + record + ) + } + const stoppedAfterLaunch = async ( + launched: AgentChatPermissionMode, + current: () => Promise + ) => + (await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current)) + .stopAgent.mock.calls.length > 0 + + it('restarts Full access or Accept edits into Ask when auto is not confirmed', async () => { + const unconfirmed = [ + launch({ model: 'haiku', permissionMode: 'auto' }), + launch({ permissionMode: 'auto' }, [{ id: 'opus[1m]', isDefault: true }]) + ] + for (const autoPick of unconfirmed) { + await expect(autoPick()).resolves.toBe('ask') + expect(await stoppedAfterLaunch('full-access', autoPick)).toBe(true) + expect(await stoppedAfterLaunch('accept-edits', autoPick)).toBe(true) + expect(await stoppedAfterLaunch('ask', autoPick)).toBe(false) + } + }) + + it('never restarts a child it just started, from an alias or a resolved id', async () => { + const records: Record[] = [ + { permissionMode: 'auto' }, + { model: 'default', permissionMode: 'auto' }, + { model: 'opus[1m]', permissionMode: 'auto' }, + { model: 'sonnet', permissionMode: 'auto' }, + { model: 'haiku', permissionMode: 'auto' }, + { model: 'claude-opus-5-5[1m]', permissionMode: 'auto' } + ] + const runsAuto = [true, true, true, true, false, false] + for (const [index, options] of records.entries()) { + const current = launch(options) + const launched = await current() + expect(launched).toBe(runsAuto[index] ? 'auto' : 'ask') + expect(await stoppedAfterLaunch(launched!, current)).toBe(false) + } + }) + + // A resolved id 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 () => { + 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 73% 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..77b90764aabd 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,5 @@ -// 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 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' @@ -13,9 +13,10 @@ import { type StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +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' @@ -31,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 @@ -78,30 +79,47 @@ function owesWakeUp( ) } +/** Loop-free: a start launches with the same `current` this compares against. */ +async function launchedWithOtherPermissionMode( + child: OutgrownChildReading['child'], + current: (() => Promise) | undefined +): Promise { + if (!child || child.phase === 'starting' || child.close || !child.permissionMode || !current) { + return false + } + 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 * 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, + session: OutgrownChildReading | 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 3604d94e0cc5..8dffa04c3d6b 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 @@ -126,7 +126,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 e3cdffb56d60..9e1b0d05f642 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 @@ -21,7 +21,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)) { @@ -93,7 +92,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/__snapshots__/structured-agent-launch-parity.test.ts.snap b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap index 9d61ef0b4188..6a47a2c718a7 100644 --- a/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap +++ b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap @@ -76,7 +76,9 @@ exports[`native-chat launch parity > claude floating fresh 1`] = ` "nodeEnv": {}, }, }, - "launchPosture": {}, + "launchPosture": { + "permissionMode": "default", + }, "pinnedLaunchDirectories": [ "/floating", ], @@ -159,7 +161,9 @@ exports[`native-chat launch parity > claude floating resumed 1`] = ` "nodeEnv": {}, }, }, - "launchPosture": {}, + "launchPosture": { + "permissionMode": "default", + }, "pinnedLaunchDirectories": [], } `; @@ -240,7 +244,9 @@ exports[`native-chat launch parity > claude worktree fresh 1`] = ` "nodeEnv": {}, }, }, - "launchPosture": {}, + "launchPosture": { + "permissionMode": "default", + }, "pinnedLaunchDirectories": [], } `; @@ -398,7 +404,9 @@ exports[`native-chat launch parity > claude worktree fresh worker 1`] = ` "nodeEnv": {}, }, }, - "launchPosture": {}, + "launchPosture": { + "permissionMode": "default", + }, "pinnedLaunchDirectories": [], } `; @@ -479,7 +487,9 @@ exports[`native-chat launch parity > claude worktree resumed 1`] = ` "nodeEnv": {}, }, }, - "launchPosture": {}, + "launchPosture": { + "permissionMode": "default", + }, "pinnedLaunchDirectories": [], } `; @@ -625,6 +635,7 @@ exports[`native-chat launch parity > codex floating fresh 1`] = ` "launchPosture": { "permissionPolicy": { "approvalPolicy": "on-request", + "approvalsReviewer": "user", "sandbox": "workspace-write", }, }, @@ -693,6 +704,7 @@ exports[`native-chat launch parity > codex floating resumed 1`] = ` "launchPosture": { "permissionPolicy": { "approvalPolicy": "on-request", + "approvalsReviewer": "user", "sandbox": "workspace-write", }, }, @@ -759,6 +771,7 @@ exports[`native-chat launch parity > codex worktree fresh 1`] = ` "launchPosture": { "permissionPolicy": { "approvalPolicy": "on-request", + "approvalsReviewer": "user", "sandbox": "workspace-write", }, }, @@ -824,6 +837,7 @@ exports[`native-chat launch parity > codex worktree fresh configured 1`] = ` "launchPosture": { "permissionPolicy": { "approvalPolicy": "never", + "approvalsReviewer": "user", "sandbox": "danger-full-access", }, }, @@ -891,6 +905,7 @@ exports[`native-chat launch parity > codex worktree fresh worker 1`] = ` "launchPosture": { "permissionPolicy": { "approvalPolicy": "on-request", + "approvalsReviewer": "user", "sandbox": "workspace-write", }, }, @@ -957,6 +972,7 @@ exports[`native-chat launch parity > codex worktree resumed 1`] = ` "launchPosture": { "permissionPolicy": { "approvalPolicy": "on-request", + "approvalsReviewer": "user", "sandbox": "workspace-write", }, }, @@ -1022,6 +1038,7 @@ exports[`native-chat launch parity > codex worktree resumed configured 1`] = ` "launchPosture": { "permissionPolicy": { "approvalPolicy": "never", + "approvalsReviewer": "user", "sandbox": "danger-full-access", }, }, 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 2c1ac6c87d61..335727dada0a 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' @@ -94,10 +94,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 4fec277d9805..4cea4007910a 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -46,8 +46,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' @@ -226,12 +224,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-pty-foreground-process-reads.ts b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts index b711805f087b..12eb7cddb130 100644 --- a/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts +++ b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts @@ -16,7 +16,7 @@ import type { MemorySnapshot, StatsSummary } from '../../shared/process-stats-ty import { collectMemorySnapshot } from '../memory/collector' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { FeatureInteractionId } from '../../shared/feature-interactions' -import type { RuntimeClientSettingsUpdate } from './runtime-client-settings' +import type { RuntimeClientSettingsUpdate } from './runtime-client-settings-shape' import type { AgentLaunchSettingsMutation } from '../../shared/agent-launch-settings' import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types' import type { TerminalQuickCommandMutation } from '../../shared/terminal-quick-commands' 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 9ce82c0681bd..efa3d531f1ac 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-shape.ts b/src/main/runtime/runtime-client-settings-shape.ts new file mode 100644 index 000000000000..ad2a699cd875 --- /dev/null +++ b/src/main/runtime/runtime-client-settings-shape.ts @@ -0,0 +1,74 @@ +// What a paired client reads and may write of this host's settings. + +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { SourceControlAiActionDefaults } from '../../shared/source-control-ai-actions' + +export type RuntimeClientSettings = Pick< + GlobalSettings, + | 'defaultTuiAgent' + | 'disabledTuiAgents' + | 'agentCmdOverrides' + | 'agentDefaultArgs' + | 'agentDefaultEnv' + | 'agentStatusHooksEnabled' + | 'terminalCopyTrimsGutter' + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'visibleTaskProviders' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + | 'experimentalNewWorktreeCardStyle' + | 'experimentalNativeChat' + | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' + | 'minimaxEndpoint' + | 'zcodePlanSite' + | 'prBotAuthorOverrides' + | 'artifactSharingEnabled' + | 'worktreeVisibilityDefaults' + | 'agentSkillSharingEnabled' + | 'machineName' + | 'nativeChatPermissionMode' +> & { + /** Older clients still read these; neither is persisted or accepted as an update. */ + openAgentTabsInChatByDefault?: boolean + experimentalStructuredNativeChat?: boolean + hostSettingOverrides: RuntimeHostDisplayLabelOverrides + sourceControlAi: RuntimeClientSourceControlAi +} + +/** The saved per-action launch recipes (agent, prompt template, agent args), already migrated. */ +export type RuntimeClientSourceControlAi = { actions: SourceControlAiActionDefaults } + +/** Safe paired projection: host labels only; filesystem defaults stay host-private. */ +export type RuntimeHostDisplayLabelOverrides = Partial< + Record +> + +export type RuntimeClientSettingsUpdate = Pick< + Partial, + | 'agentStatusHooksEnabled' + | 'defaultTuiAgent' + | 'disabledTuiAgents' + | 'agentDefaultArgs' + | 'agentDefaultEnv' + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'visibleTaskProviders' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + | 'experimentalNewWorktreeCardStyle' + | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' + | 'minimaxEndpoint' + | 'zcodePlanSite' + | 'prBotAuthorOverrides' + | 'worktreeVisibilityDefaults' + | 'machineName' + | 'nativeChatPermissionMode' +> diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 68a2169980c6..75fafa74103b 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -10,15 +10,11 @@ import { } from '../../shared/terminal-quick-commands' import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection' import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai' -import { - SOURCE_CONTROL_LAUNCH_ACTION_IDS, - type SourceControlAiActionDefaults -} from '../../shared/source-control-ai-actions' +import { SOURCE_CONTROL_LAUNCH_ACTION_IDS } from '../../shared/source-control-ai-actions' import type { GlobalSettings } from '../../shared/global-settings-types' import { applyNativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-option-defaults' import type { NativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-options' import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides' -import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types' import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' @@ -31,73 +27,12 @@ import { type AgentLaunchSettingsMutation } from '../../shared/agent-launch-settings' import { agentLaunchSettingsRollbackUpdates } from './agent-launch-settings-rollback' - -export type RuntimeClientSettings = Pick< - GlobalSettings, - | 'defaultTuiAgent' - | 'disabledTuiAgents' - | 'agentCmdOverrides' - | 'agentDefaultArgs' - | 'agentDefaultEnv' - | 'agentStatusHooksEnabled' - | 'terminalCopyTrimsGutter' - | 'defaultTaskSource' - | 'defaultTaskViewPreset' - | 'visibleTaskProviders' - | 'defaultRepoSelection' - | 'defaultLinearTeamSelection' - | 'githubProjects' - | 'experimentalNewWorktreeCardStyle' - | 'experimentalNativeChat' - | 'compactWorktreeCards' - | 'minimaxGroupId' - | 'minimaxUsageModels' - | 'minimaxEndpoint' - | 'zcodePlanSite' - | 'prBotAuthorOverrides' - | 'artifactSharingEnabled' - | 'worktreeVisibilityDefaults' - | 'agentSkillSharingEnabled' - | 'machineName' -> & { - /** Older clients still read these; neither is persisted or accepted as an update. */ - openAgentTabsInChatByDefault?: boolean - experimentalStructuredNativeChat?: boolean - hostSettingOverrides: RuntimeHostDisplayLabelOverrides - sourceControlAi: RuntimeClientSourceControlAi -} - -/** The saved per-action launch recipes (agent, prompt template, agent args), already migrated. */ -export type RuntimeClientSourceControlAi = { actions: SourceControlAiActionDefaults } - -/** Safe paired projection: host labels only; filesystem defaults stay host-private. */ -export type RuntimeHostDisplayLabelOverrides = Partial< - Record -> - -export type RuntimeClientSettingsUpdate = Pick< - Partial, - | 'agentStatusHooksEnabled' - | 'defaultTuiAgent' - | 'disabledTuiAgents' - | 'agentDefaultArgs' - | 'agentDefaultEnv' - | 'defaultTaskSource' - | 'defaultTaskViewPreset' - | 'visibleTaskProviders' - | 'defaultRepoSelection' - | 'defaultLinearTeamSelection' - | 'githubProjects' - | 'experimentalNewWorktreeCardStyle' - | 'compactWorktreeCards' - | 'minimaxGroupId' - | 'minimaxUsageModels' - | 'minimaxEndpoint' - | 'zcodePlanSite' - | 'prBotAuthorOverrides' - | 'worktreeVisibilityDefaults' - | 'machineName' -> +import type { + RuntimeClientSettings, + RuntimeClientSettingsUpdate, + RuntimeClientSourceControlAi, + RuntimeHostDisplayLabelOverrides +} from './runtime-client-settings-shape' export class RuntimeClientSettingsController { private reconciliationGeneration = 0 @@ -150,6 +85,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 ea6fc4451172..fda26c88034d 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -133,6 +133,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-launch-composition.test-support.ts b/src/main/runtime/structured-agent-launch-composition.test-support.ts index 0ee9a7a68665..fe344bd07cbf 100644 --- a/src/main/runtime/structured-agent-launch-composition.test-support.ts +++ b/src/main/runtime/structured-agent-launch-composition.test-support.ts @@ -2,6 +2,7 @@ // it, so resolver tests exercise the shared launch path rather than a copy of it. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' import type { AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' import { @@ -74,11 +75,19 @@ function withTestSpawnToken( ) { const resolve = composeStructuredLaunch(definition, sources, part) const read = composeStructuredLaunchRead(definition, sources, part) - return (input: { identity: AgentSessionJournalIdentity; spawnToken?: string | null }) => { + return (input: { + identity: AgentSessionJournalIdentity + spawnToken?: string | null + permissionMode?: AgentChatPermissionMode + }) => { const spawnToken = input.spawnToken === undefined ? defaultToken : input.spawnToken return spawnToken === null ? read(input.identity) - : resolve({ identity: input.identity, spawnToken }) + : resolve({ + identity: input.identity, + spawnToken, + ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}) + }) } } diff --git a/src/main/runtime/structured-agent-launch-composition.ts b/src/main/runtime/structured-agent-launch-composition.ts index f471be3a01fb..7c644d1fc41f 100644 --- a/src/main/runtime/structured-agent-launch-composition.ts +++ b/src/main/runtime/structured-agent-launch-composition.ts @@ -4,6 +4,7 @@ // sealed environment; it only adds what is that agent's. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { isNativeLocalLocation } from '../../shared/execution-host' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' @@ -47,12 +48,15 @@ export type StructuredAgentLaunchBasis = { /** Null when the chat gets no visuals. */ visuals(): Promise commandSettings(): StructuredAgentCommandSettings + /** The permission mode the host decided this start runs with; absent asks. */ + permissionMode?: AgentChatPermissionMode } export type StructuredLaunchRequest = { identity: AgentSessionJournalIdentity /** The reservation's token, which the seal hands the child. */ spawnToken: string + permissionMode?: AgentChatPermissionMode } export type StructuredAgentLaunchPart = { @@ -126,7 +130,8 @@ async function composedPart( definition: StructuredAgentDefinition, sources: StructuredAgentLaunchSources, part: StructuredAgentLaunchPartResolver, - identity: AgentSessionJournalIdentity + identity: AgentSessionJournalIdentity, + permissionMode?: AgentChatPermissionMode ): Promise & { cwd: string }> { const record = admittedRecord(definition, sources.store, identity.sessionId) const launchEnv = once(() => sources.resolveAgentLaunchEnv?.(definition.agent) ?? {}) @@ -137,7 +142,8 @@ async function composedPart( launchEnv, environment: once(() => sources.resolveAgentEnvironment(definition.agent)), visuals: once(async () => (await sources.prepareVisuals?.(record.sessionId)) ?? null), - commandSettings: once(() => sources.resolveCommandSettings?.() ?? {}) + commandSettings: once(() => sources.resolveCommandSettings?.() ?? {}), + ...(permissionMode ? { permissionMode } : {}) } const composed = await part(basis, identity) return { ...composed, cwd: await basis.launchDirectory() } @@ -148,12 +154,13 @@ export function composeStructuredLaunch( sources: StructuredAgentLaunchSources, part: StructuredAgentLaunchPartResolver ): StructuredAgentLaunchResolver { - return async ({ identity, spawnToken }) => { + return async ({ identity, spawnToken, permissionMode }) => { const { launch, cwd, env, inheritedEnvToDelete, cliRuntimeCommand } = await composedPart( definition, sources, part, - identity + identity, + permissionMode ) const child = sealStructuredSessionChild({ sessionId: identity.sessionId, diff --git a/src/main/runtime/structured-agent-launch-parity.test.ts b/src/main/runtime/structured-agent-launch-parity.test.ts index ba7e25bd8913..7f4faca8242f 100644 --- a/src/main/runtime/structured-agent-launch-parity.test.ts +++ b/src/main/runtime/structured-agent-launch-parity.test.ts @@ -126,6 +126,7 @@ import { } from '../../shared/agent-hook-scrub-safe-env' import { STRUCTURED_WORKER_HANDLE_PREFIX } from '../../shared/structured-worker-handle' import { YOLO_TUI_AGENT_ARGS } from '../../shared/tui-agent-permissions' +import { agentChatPermissionModes } from '../../shared/agent-chat-permission-mode' import { AGENT_HOOK_RUNTIME_ENV_KEYS, ORCA_AGENT_SESSION_CALLER_ENV_KEYS @@ -637,6 +638,10 @@ describe.skipIf(process.platform === 'win32')('native-chat launch parity', () => }, fence: 1, spawnToken: SPAWN_TOKEN, + // Claude and Codex chats run their own mode, which the host hands the start. + ...(launch.configured && agentChatPermissionModes(launch.agent) + ? { permissionMode: 'full-access' as const } + : {}), events: eventSink() } capture.spawns.length = 0 diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 3492c0e09ca2..42347e7a6976 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionAccountHome, AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' +import { agentChatPermissionModes } from '../../shared/agent-chat-permission-mode' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, @@ -33,6 +34,9 @@ import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/struc import { PI_RPC_AGENT } from '../pi/rpc-agent-definition' import { piModelCatalogFromListing } from '../pi/rpc-model-catalog-probe' +// A chat whose agent offers permission modes also names its own, which a new chat leaves at Ask. +const modeOf = (agent: string) => (agentChatPermissionModes(agent) ? { permissionMode: 'ask' } : {}) + const unused = (): never => { throw new Error('an at-rest read never calls the adapter') } @@ -259,7 +263,7 @@ describe("a new chat's effort pill before its agent starts", () => { expect(first.model?.value).toBe(model) expect(first.effort?.value).toBe(effort) expect(settled).toEqual(first) - expect(atRest.current).toEqual({ model, effort }) + expect(atRest.current).toEqual({ model, effort, ...modeOf(agent) }) } ) @@ -276,7 +280,7 @@ describe("a new chat's effort pill before its agent starts", () => { expect(first.model?.value).toBeUndefined() expect(first.effort).toBeNull() expect(settled).toEqual(first) - expect(atRest.current).toEqual({}) + expect(atRest.current).toEqual(modeOf(agent)) } ) @@ -287,14 +291,14 @@ describe("a new chat's effort pill before its agent starts", () => { }) // The account default is not what this repo runs, so its effort is never claimed. - expect(atRest.current).toEqual({ model: 'gpt-5.5' }) + expect(atRest.current).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' }) expect(atRest.conversationCommands).toEqual(['clear', 'compact']) }) it('names the folder’s default effort for a client that needs a model too', async () => { const { atRest } = await newChatFrames('codex', { readsWithoutModel: false }) - expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium' }) + expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium', permissionMode: 'ask' }) }) it('keeps a saved effort over the default model’s', async () => { @@ -307,6 +311,6 @@ describe("a new chat's effort pill before its agent starts", () => { it('leaves the effort of a saved model to the agent, as its running child reports it', async () => { const { atRest } = await newChatFrames('codex', { saved: { model: 'gpt-5.5-mini' } }) - expect(atRest.current).toEqual({ model: 'gpt-5.5-mini' }) + expect(atRest.current).toEqual({ model: 'gpt-5.5-mini', permissionMode: 'ask' }) }) }) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 75d406b5974b..746f55af3f91 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -141,9 +141,6 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA structuredAgentLaunchSources(context), codexStructuredLaunchPart({ resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), - ...(deps.resolveCodexPermissionPolicy - ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } - : {}), ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) }) ), @@ -181,9 +178,6 @@ function createClaudeAdapter( resolveLaunchArgs: () => deps.resolveLaunchArgs('claude'), ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), resolveAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudePermissionMode - ? { resolvePermissionMode: deps.resolveClaudePermissionMode } - : {}), attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), ...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {}) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 637cc61aaf2a..11afc2d16f46 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 { readCodexCliInstallation } from '../preflight/codex-cli-installation' @@ -123,11 +121,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] /** 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. */ + /** 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 from settings, laid over every agent's base env. */ resolveAgentLaunchEnv?: (agent: string) => Record diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 7bc1dba7d97f..812ad66c6095 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -404,6 +404,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 81a646de2d13..40448514657e 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..71ce69ccee3c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx @@ -0,0 +1,252 @@ +// @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('Never asks before edits or commands')).toBeTruthy() + }) + + it("describes Codex's modes as Codex runs them", () => { + render( + + ) + expect(screen.queryByText('Accept edits')).toBeNull() + expect(option('ask').textContent).toContain( + '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' + ) + }) + + it('describes both agents in the new-chat setting', () => { + render() + expect(option('ask').textContent).toContain( + "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', () => { + 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() + // 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', () => { + 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..49b21aaec912 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx @@ -0,0 +1,156 @@ +import { memo } 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) + +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, + agent +}: { + surface: SessionOptionsSurface | null + snapshot: readonly SessionOptionDescriptor[] + agent?: string +}): React.JSX.Element | null { + 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) { + // 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/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 b3efd9fa4c65..2445b0d70b17 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -86,6 +86,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 c4290d70c171..cd32e239fa02 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -63,6 +63,23 @@ export function useStructuredAgentSessionStatusSummary( return { summary, observation } } +/** 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 +): 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 +108,19 @@ export function useStructuredAgentSessionHostStopping( ) } -/** The host's startup phase and its word on a Stop, 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 -): { 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..75426c5e4034 --- /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', + 'Edits files and runs commands in this workspace; asks before anything else' + ) + } + if (agent !== 'claude') { + return translate( + 'components.native-chat.composer.permissionAskSharedDescription', + "Asks before edits or commands it isn't already allowed to make" + ) + } + 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; can edit any file and use the network' + ) + } + if (agent !== 'claude') { + return translate( + 'components.native-chat.composer.permissionFullAccessSharedDescription', + 'Never asks before edits or commands' + ) + } + return translate( + 'components.native-chat.composer.permissionFullAccessDescription', + 'Never asks before edits or commands' + ) + } +} + +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-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 416bdbce0f97..1722720a7b3f 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 0ef2f0397278..a702aa38fa62 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, @@ -116,10 +119,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 @@ -169,6 +174,8 @@ export function useStructuredAgentSessionOptionState(args: { }, [ fence, optionCatalog, + // Another device's pick moves it. + hostOptionStamp, providerStarting, providerVisible, readsBeforeStart, @@ -188,8 +195,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.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..3041328fadad --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import { act, 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() + }) +}) + +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 d86825b7c906..c2021a3008ff 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, @@ -43,6 +44,7 @@ export function useStructuredAgentSessionOptions(args: { providerVisible: boolean providerStarting?: boolean providerRunning?: boolean + hostOptionStamp?: string fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined @@ -73,6 +75,7 @@ export function useStructuredAgentSessionOptions(args: { pendingOptionRef, optionMutationGeneration, updateOptionState, + rereadOptions, conversationSupport } = useStructuredAgentSessionOptionState({ agent, @@ -86,6 +89,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', @@ -110,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. @@ -131,10 +136,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 } @@ -151,6 +167,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', @@ -167,8 +188,6 @@ export function useStructuredAgentSessionOptions(args: { }) .catch(() => {}) } - return Boolean(result) - } finally { if (isCurrent()) { pendingOptionRef.current = null updateOptionState((current) => 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 b471167ca5f8..8e665d1c3f8c 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 }) { @@ -115,6 +117,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 8bc406a07002..47b9cb7a11c5 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' @@ -72,7 +73,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 7e663834e007..a6ab1d64099a 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.tsx @@ -2,6 +2,7 @@ import { translate } from '@/i18n/i18n' import { AppearanceChatSection } from './AppearanceChatSection' import { ChatBehaviorSetting } from './ChatBehaviorSetting' import { ChatNamingSetting } from './ChatNamingSetting' +import { ChatPermissionSetting } from './ChatPermissionSetting' import { NativeChatInlineVisualsSetting } from './NativeChatInlineVisualsSetting' import { SettingsSection } from './SettingsSection' import { SettingsSubsectionHeader } from './SettingsFormControls' @@ -56,6 +57,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 c4da94ca015c..98c3070b6909 100644 --- a/src/renderer/src/hooks/settings-navigation-interface-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-interface-sections.ts @@ -3,6 +3,7 @@ import { getChatAppearanceSearchEntries } from '@/components/settings/chat-appea import { getChatBehaviorSearchEntries } from '@/components/settings/chat-behavior-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' @@ -49,6 +50,7 @@ export function buildInterfaceSettingsSections({ ), icon: MessageSquare, searchEntries: [ + getChatPermissionSearchEntry(), ...getChatAppearanceSearchEntries(), ...getChatBehaviorSearchEntries().filter( (entry) => hostQueuesMessages || entry.targetSectionId !== 'chat-queue-follow-ups' diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 41e97cb7d02a..af579ac21fe5 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2844,7 +2844,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 e2e3a38c07d2..47918f63536f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -184,6 +184,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", @@ -18337,6 +18339,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": "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": "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 8906f2d83809..ff33ef7f4d47 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", @@ -17988,6 +17990,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": "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": "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 b3b4b2fafdd7..2b3bf9eca364 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", @@ -18090,6 +18092,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": "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": "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 a852837556da..47874e9d9f26 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": "チャット名", @@ -18023,6 +18025,19 @@ "attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。", "attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。", "tooManyAttachments": "一度に添付できるファイルは {{value0}} 個以下です。", + "permissions": "権限", + "permissionAsk": "承認を求める", + "permissionAskDescription": "設定で許可されていない編集やコマンドの前に確認します", + "permissionAskCodexDescription": "このワークスペース内でファイルを編集しコマンドを実行します。それ以外の操作の前に確認します", + "permissionAskSharedDescription": "まだ許可されていない編集やコマンドの前に確認します", + "permissionAcceptEdits": "編集を許可", + "permissionAcceptEditsDescription": "ファイルの編集とファイル操作コマンドを承認し、それ以外は確認します", + "permissionAuto": "代わりに承認", + "permissionAutoDescription": "承認リクエストを代わりに審査します", + "permissionFullAccess": "フルアクセス", + "permissionFullAccessDescription": "編集やコマンドの前に確認しません", + "permissionFullAccessCodexDescription": "確認しません。任意のファイルを編集し、ネットワークを使用できます", + "permissionFullAccessSharedDescription": "編集やコマンドの前に確認しません", "pasteFailed": "貼り付けに失敗しました。", "commandAttachmentsUnsupported": "チャットセッションのコマンドを使う前に添付ファイルを削除してください。" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0e8217e22458..d6688c78ee1f 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": "채팅 이름", @@ -18023,6 +18025,19 @@ "attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.", "attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.", "tooManyAttachments": "한 번에 {{value0}}개 이하의 파일을 첨부하세요.", + "permissions": "권한", + "permissionAsk": "승인 요청", + "permissionAskDescription": "설정에서 허용하지 않은 편집과 명령 전에 묻습니다", + "permissionAskCodexDescription": "이 작업 공간에서 파일을 편집하고 명령을 실행하며, 그 외 작업 전에는 묻습니다", + "permissionAskSharedDescription": "아직 허용되지 않은 편집이나 명령 전에 묻습니다", + "permissionAcceptEdits": "편집 허용", + "permissionAcceptEditsDescription": "파일 편집과 파일 명령은 승인하고, 나머지는 묻습니다", + "permissionAuto": "대신 승인", + "permissionAutoDescription": "승인 요청을 대신 검토합니다", + "permissionFullAccess": "전체 액세스", + "permissionFullAccessDescription": "편집이나 명령 전에 묻지 않습니다", + "permissionFullAccessCodexDescription": "묻지 않습니다. 모든 파일을 편집하고 네트워크를 사용할 수 있습니다", + "permissionFullAccessSharedDescription": "편집이나 명령 전에 묻지 않습니다", "pasteFailed": "붙여넣기 실패.", "commandAttachmentsUnsupported": "채팅 세션 명령을 사용하기 전에 첨부 파일을 제거하세요." }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index edfaed1a3b60..e33c7300ca05 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": "聊天名称", @@ -18002,6 +18004,19 @@ "attachmentOwnerChanged": "此工作区在附加时更改了主机 - 再次删除文件。", "attachmentUnreadable": "无法读取删除的文件。", "tooManyAttachments": "每次最多附加 {{value0}} 个文件。", + "permissions": "权限", + "permissionAsk": "请求批准", + "permissionAskDescription": "在设置未允许的编辑和命令前询问", + "permissionAskCodexDescription": "在此工作区中编辑文件并运行命令;其他操作前会询问", + "permissionAskSharedDescription": "在执行尚未获准的编辑或命令前询问", + "permissionAcceptEdits": "接受编辑", + "permissionAcceptEditsDescription": "批准文件编辑和文件命令;其余操作会询问", + "permissionAuto": "替我批准", + "permissionAutoDescription": "代你审核批准请求", + "permissionFullAccess": "完全访问", + "permissionFullAccessDescription": "编辑或运行命令前从不询问", + "permissionFullAccessCodexDescription": "从不询问;可以编辑任何文件并使用网络", + "permissionFullAccessSharedDescription": "编辑或运行命令前从不询问", "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 0d0e4e26f858..f941f9e40d7a 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 0973945555e4..0b18e0c253ce 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..4f1fa26175a5 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,12 @@ export function mergeSettings( }) } } + +/** The host's new-chat permission default (the host seeds from it), kept only when a known mode. */ +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..be949d7a5305 --- /dev/null +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_CHAT_PERMISSION_MODES, + agentChatPermissionModes, + chatModelSupportsAutoMode, + chatPermissionModeNeedsModelSupport, + effectiveChatPermissionMode, + loadedChatPermissionSetting, + newChatPermissionMode, + offeredChatPermissionModes +} from './agent-chat-permission-mode' +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']) + }) + + 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') + }) + + // 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 new file mode 100644 index 000000000000..478b3019c886 --- /dev/null +++ b/src/shared/agent-chat-permission-mode.ts @@ -0,0 +1,116 @@ +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 +} + +const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: AGENT_CHAT_PERMISSION_MODES, + autoNeedsModelSupport: true +} + +const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: ['ask', 'auto', 'full-access'] +} + +/** 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 + : 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' +} + +/** 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, + intent: unknown +): boolean { + return ( + support.autoNeedsModelSupport === true && savedChatPermissionMode(support, intent) === 'auto' + ) +} + +/** Saved choice, with `auto` on a model not listed for it read as Ask; never written back. */ +export function effectiveChatPermissionMode( + support: AgentChatPermissionModeSupport, + intent: unknown, + autoSupported: boolean +): AgentChatPermissionMode { + const mode = savedChatPermissionMode(support, intent) + return mode === 'auto' && support.autoNeedsModelSupport && !autoSupported ? 'ask' : mode +} + +export function offeredChatPermissionModes( + support: AgentChatPermissionModeSupport, + autoSupported: boolean +): 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 +} + +/** Seeded once from Agent Permissions when absent, then stored; an unknown value 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 b8ff6a140556..9a8fed9c76c2 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,8 @@ export type AgentSessionModelOption = { efforts: AgentSessionOptionChoice[] /** Provider catalog fact. Absent means the host could not determine support. */ supportsFastMode?: boolean + /** 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 * (`default` for standard) in place of the Fast toggle. */ @@ -473,9 +477,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. */ @@ -503,6 +505,8 @@ export type AgentSessionOptionsResult = { fastMode?: boolean /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ serviceTier?: string + /** 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/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 9be7f5c3a120..113d69aa8595 100644 --- a/src/shared/claude-model-list-probe.ts +++ b/src/shared/claude-model-list-probe.ts @@ -31,6 +31,10 @@ 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 + /** 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 = { @@ -53,6 +57,7 @@ type RawListedModel = { supportsEffort?: unknown supportedEffortLevels?: unknown supportsFastMode?: unknown + supportsAutoMode?: unknown disabled?: unknown } @@ -80,10 +85,21 @@ 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 } : {}) } } +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() @@ -106,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/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..a80224878239 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,8 @@ 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 + /** 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[] /** 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..82b0c0efefa6 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,12 @@ export const STRUCTURED_LAUNCH_SEED_OPTION_IDS = [ 'serviceTier' ] as const +/** 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 +] 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 +100,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 +115,21 @@ export function resolveStructuredLaunchSeedOptions( return narrowStructuredLaunchSeedOptions(resolveNativeChatSessionOptionDefaults(persisted, agent)) } +/** A new chat's seed: the saved selection plus the permission default narrowed to the agent. */ +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..5920e68ae173 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,8 @@ 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 permission mode the host or launch last stated; null shows no picker. */ + permissionMode: string | null pendingId: string | null } @@ -60,6 +67,7 @@ export function createStructuredAgentSessionOptionState( catalog: seedCatalog ?? null, catalogSource: seedCatalog ? 'seed' : null, record: createNativeChatSessionOptionRecord(agent), + permissionMode: null, pendingId: null } } @@ -138,7 +146,8 @@ export function applyStructuredAgentSessionOptions( return { ...state, catalog: structuredAgentSessionOptionCatalog(seed, result), - catalogSource: 'live' + catalogSource: 'live', + permissionMode: result.current.permissionMode ?? null } } @@ -158,6 +167,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 +226,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 +250,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..93f981857db0 --- /dev/null +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -0,0 +1,161 @@ +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, + 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: '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; can edit any file and use the network' + } + ]) + }) + + 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']) + }) + + // 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' } + 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..9bf74de0a2d5 --- /dev/null +++ b/src/shared/structured-agent-session-permission-option.ts @@ -0,0 +1,82 @@ +// 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, + chatModelSupportsAutoMode, + offeredChatPermissionModes, + type AgentChatPermissionMode +} from './agent-chat-permission-mode' +import type { AgentSessionOptionCatalog } from './agent-session-option-catalog' +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: 'Never asks before edits or commands' + } + } + +const CODEX_DESCRIPTIONS: Partial> = { + 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, by the host's own rule. */ +function currentModelSupportsAutoMode( + catalog: AgentSessionOptionCatalog | null, + record: NativeChatSessionOptionRecord +): boolean { + const model = record.model?.value + return ( + catalog !== null && + chatModelSupportsAutoMode(catalog.models, typeof model === 'string' ? model : undefined) + ) +} + +export function structuredAgentSessionPermissionDescriptor(state: { + catalog: AgentSessionOptionCatalog | null + record: NativeChatSessionOptionRecord + permissionMode: string | null +}): SessionOptionDescriptor | null { + const support = agentChatPermissionModes(state.record.agent) + const current = support?.modes.find((mode) => mode === state.permissionMode) + if (!support || !current) { + return null + } + 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: current, + choices: offered.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 + } +} 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..3c45b200dd5d --- /dev/null +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -0,0 +1,218 @@ +// @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 { + IN_PROCESS_OPTIONS_READER, + 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, + recordWorkspacePath: async () => join(dir, 'project'), + 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, IN_PROCESS_OPTIONS_READER) + : 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() + }) +})