From 23798eff7c8db8a30231c076991915a9a99e61c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:00:15 -0700 Subject: [PATCH] Let chats follow the agent permission setting (#32334) * feat(agents): store each agent's permission itself instead of typing flags into Arguments Terminals and new chats read one stored choice per agent (Use the agent's own settings, Ask for approval, Accept edits, Approve for me, Full access). Orca adds the flag at launch; Arguments hold only the user's own text. A one-time move lifts Orca's flags out of saved Arguments. Older clients still see and write the flag as part of Arguments; the host translates. * fix(agents): say the all-agents switch resets agents set separately; keep primitives unstyled * test(agents): the one-time move returns every launch-profile field * Fix permission carrier ownership and surface-specific defaults * Preserve uncertain permission operands and name the option inventory * Check env entries without indexing typed agent maps * Combine Chat permission capability imports * Use folder execution-host platform for permission environment * Preserve launch map replacement and acknowledge permission saves * Keep permission feedback fallbacks statically discoverable * Provide preload platform to Agents settings render tests * Preserve Gemini approval arguments under Full access * fix(settings): retain complete environment edits through save outcomes * fix(settings): narrow local failure scope and preserve host messages * fix(settings): retire argument drafts after acknowledged saves * fix(settings): keep Goose environment editable after reset * fix(settings): show resolved arguments for legacy hosts * Preserve pending environment resets and bound launch settings work * Use agent permission choices for new chats * Let Claude resolve chat permissions from its own settings * Keep optional settings fallback in repository metadata reads * Consolidate permission report imports * Update permission choice contracts for own settings * Read repository visibility defaults through the raw settings fixtures * Match existing translated Agents settings labels * Update readback and legacy host test expectations * Align Chat behavior tests with host permission capability * Fix permission compatibility and host-owned descriptions * Prepare whole Environment edits and retire unchanged drafts * Handle null explicitly in permission report decoder * Keep Environment settings transitions in an acyclic module * Update raw settings reader test fixtures * Place test fixture safety notes on assertion starts * test: pin permission settings work and recovery contracts * test: type permission settings work fixtures * test: cover migrated permission inputs and captured CLI grammar * Publish permission reports and reuse host capability state * Combine mobile option state React imports * Honor mobile status feed capability for permission reports * test: preserve typed permissions in WSL Claude restarts * Correct permission report child and fixture type contracts * Delegate older-host chats to explicitly selected own settings * Avoid rebuilding live ACP option catalogs * test(chat): place cross-boundary permission contracts in integration project * Preserve existing chat capability gates and complete report test fixtures * fix(chat): preserve newer Claude startup permission reports * test(chat): clarify startup report fixture provenance * Show reported permission modes in chat menus * Keep reported permissions available after chat mode picks * Remove obsolete guard from concrete permission selection * Report the installed approval policy for ACP chat menus * test: keep accepted mobile mutation fixture on current fence * test: cover installed ACP approval report for own chats * test: align capability scaling contract with concrete permission menus * Ship required permission help in English runtime catalog * Adapt permission persistence test to shared menu --- ...bileNativeChatSessionOptionPickers.test.ts | 52 +- .../MobileNativeChatSessionOptionPickers.tsx | 17 +- ...obile-native-chat-session-option-labels.ts | 5 +- ...uctured-agent-session-host-support.test.ts | 1 + ...e-structured-agent-session-host-support.ts | 7 +- ...ructured-agent-options.first-frame.test.ts | 2 +- ...ured-agent-options.permission-mode.test.ts | 43 +- ...se-mobile-structured-agent-options.test.ts | 25 +- .../use-mobile-structured-agent-options.ts | 24 +- ...ctured-agent-session-hold-refused.test.tsx | 9 +- ...red-agent-session.cut-turn-notice.test.tsx | 6 +- ...e-mobile-structured-agent-session.test.tsx | 43 +- .../use-mobile-structured-option-state.ts | 25 +- .../use-mobile-structured-option-surface.ts | 5 +- ...-mobile-structured-turn-indicator.test.tsx | 21 +- src/main/acp/acp-structured-acquire.ts | 5 +- .../acp-structured-adapter.test-support.ts | 4 +- .../acp/acp-structured-antigravity.test.ts | 8 + src/main/acp/acp-structured-handshake.ts | 4 +- src/main/acp/acp-structured-options.test.ts | 77 ++- src/main/acp/acp-structured-options.ts | 31 +- .../acp-structured-publish-at-spawn.test.ts | 46 ++ .../acp-structured-session-adapter-deps.ts | 4 +- .../acp/acp-structured-session-adapter.ts | 6 +- .../claude-agent-sdk-contract-pins.test.ts | 24 +- .../claude/claude-structured-init-proof.ts | 42 +- .../claude-structured-launch-resolution.ts | 22 +- .../claude-structured-session-acquisition.ts | 30 +- ...e-structured-session-adapter-turns.test.ts | 2 + .../claude-structured-session-options.ts | 1 + .../claude-structured-session-startup.test.ts | 91 ++++ .../claude-structured-session-startup.ts | 5 + .../claude/claude-structured-session-state.ts | 21 +- ...codex-structured-launch-resolution.test.ts | 9 + .../codex-structured-launch-resolution.ts | 6 +- .../codex-structured-service-tier.test.ts | 1 + .../codex/codex-structured-service-tier.ts | 1 + .../codex-structured-session-adapter.test.ts | 8 +- .../codex/codex-structured-session-options.ts | 10 +- .../codex/codex-structured-session-state.ts | 1 + .../codex-structured-thread-open.test.ts | 34 ++ .../codex/codex-structured-thread-open.ts | 27 ++ .../structured-agent-session-attach-flow.ts | 4 +- .../structured-agent-session-host-types.ts | 4 +- ...uctured-agent-session-launch-permission.ts | 4 +- .../structured-agent-session-option-intent.ts | 4 +- .../structured-agent-session-options-read.ts | 7 +- ...tured-agent-session-outgrown-child.test.ts | 10 +- ...structured-agent-session-outgrown-child.ts | 6 +- ...ructured-agent-session-provider-started.ts | 20 +- .../structured-agent-session-ran-options.ts | 28 +- ...-agent-session-startup-attempt-contract.ts | 5 +- ...agent-session-startup-attempt-host.test.ts | 57 ++- ...tructured-agent-session-startup-attempt.ts | 4 +- ...ructured-agent-session-status-feed-deps.ts | 25 + ...ructured-agent-session-status-feed.test.ts | 1 + .../structured-agent-session-status-feed.ts | 31 +- ...d-agent-session-status-summary-equality.ts | 16 + ...structured-agent-session-status-summary.ts | 5 +- .../legacy-chat-permission-write.ts | 30 ++ .../settings-update-normalization.test.ts | 22 - .../applying-settings/settings-update.ts | 13 +- .../terminal-settings-migrations.ts | 3 + .../normalize-loaded-global-settings.test.ts | 68 +-- .../normalize-loaded-global-settings.ts | 22 +- .../runtime/agent-launch-settings-rollback.ts | 4 - ...t-agent-launch-settings-durability.test.ts | 48 +- ...ntime-client-agent-launch-settings.test.ts | 8 +- .../runtime/runtime-client-settings-shape.ts | 1 - src/main/runtime/runtime-client-settings.ts | 15 +- src/main/runtime/runtime-store-contract.ts | 1 - ...d-agent-launch-composition.test-support.ts | 4 +- .../structured-agent-launch-composition.ts | 8 +- .../NativeChatPermissionModePicker.test.tsx | 69 ++- .../NativeChatPermissionModePicker.tsx | 43 +- .../NativeChatRewindAction.target.test.tsx | 1 + ...model-catalog-upgrade.unavailable.test.tsx | 1 + ...se-native-chat-provisional-launch.test.tsx | 1 + ...d-agent-session-command-send-gate.test.tsx | 1 + ...ent-session-command-start-failure.test.tsx | 1 + ...tured-agent-session-context-usage.test.tsx | 1 + ...red-agent-session-legacy-recovery.test.tsx | 1 + ...e-structured-agent-session-option-state.ts | 22 +- ...agent-session-options.first-frame.test.tsx | 3 +- ...t-session-options.learned-default.test.tsx | 1 + ...t-session-options.permission-mode.test.tsx | 66 ++- ...-session-options.preloaded-agents.test.tsx | 2 + ...-structured-agent-session-options.test.tsx | 1 + .../use-structured-agent-session-options.ts | 3 + ...tured-agent-session-prompt-cancel.test.tsx | 1 + ...ctured-agent-session-prompt-state.test.tsx | 1 + ...uctured-agent-session-provisional.test.tsx | 1 + ...ed-agent-session-question-answers.test.tsx | 1 + ...use-structured-agent-session-stop.test.tsx | 1 + ...tured-agent-session-write-refusal.test.tsx | 1 + ...ructured-agent-session.clear-send.test.tsx | 1 + ...red-agent-session.cut-turn-notice.test.tsx | 1 + ...gent-session.queue-while-stopping.test.tsx | 1 + ...tured-agent-session.queued-gating.test.tsx | 1 + ...tured-agent-session.rejected-card.test.tsx | 1 + ...e-structured-agent-session.rewind.test.tsx | 1 + .../use-structured-agent-session.test.tsx | 2 +- .../settings/AgentLaunchSettingsCatalog.tsx | 4 + .../settings/AgentLaunchSettingsSection.tsx | 3 + .../AgentPermissionChoiceControls.tsx | 21 +- .../settings/ChatBehaviorSetting.test.tsx | 25 +- .../settings/ChatBehaviorSetting.tsx | 4 +- .../settings/ChatPermissionSetting.test.tsx | 151 +++++- .../settings/ChatPermissionSetting.tsx | 73 ++- .../settings/ChatSettingsSection.test.tsx | 13 + .../settings/HostAgentLaunchSettings.test.tsx | 27 +- .../settings/HostAgentLaunchSettings.tsx | 26 +- .../agent-launch-settings-transport.ts | 8 + .../settings/chat-permission-search.ts | 3 +- .../settings/use-chat-permission-settings.ts | 79 +++ .../hooks/useSettingsNavigationMetadata.ts | 4 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/pt.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...t-session-host-capability-scaling.test.tsx | 147 ++++++ ...tructured-agent-session-host-capability.ts | 54 ++- ...gent-session-status-feed-lifecycle.test.ts | 7 +- .../structured-chat-host-permission.test.ts | 37 +- .../web-chat-permission-normalization.test.ts | 31 +- .../web-preference-normalization.ts | 24 +- .../agent-chat-permission-host-support.ts | 11 +- src/shared/agent-chat-permission-mode.test.ts | 84 +--- src/shared/agent-chat-permission-mode.ts | 88 ++-- .../agent-launch-settings-permissions.test.ts | 79 +-- src/shared/agent-launch-settings.ts | 84 +--- .../agent-scoped-permission-defaults.test.ts | 294 +----------- src/shared/agent-session-options-result.ts | 2 + ...ent-session-permission-modes-capability.ts | 4 + ...agent-session-status-snapshot-fold.test.ts | 2 +- .../agent-session-status-snapshot-fold.ts | 12 +- src/shared/agent-session-wire.ts | 1 + src/shared/global-settings-types.ts | 4 +- .../native-chat-session-option-defaults.ts | 9 +- .../structured-agent-session-options.ts | 7 +- ...ed-agent-session-permission-option.test.ts | 21 + ...uctured-agent-session-permission-option.ts | 54 ++- ...ured-agent-session-surface-capabilities.ts | 2 + .../tui-agent-permission-choice.test.ts | 8 +- .../__mocks__/mobile-structured-options.ts | 57 +++ tests/e2e/agent-auto-permissions.spec.ts | 97 ++-- ...claude-auto-mode-host-catalog.unit.test.ts | 1 + ...nch-settings-hook-bookkeeping.unit.test.ts | 1 + ...gent-session-launch-admission.unit.test.ts | 129 ++++- ...permission-report-publication.unit.test.ts | 453 ++++++++++++++++++ 154 files changed, 2741 insertions(+), 1031 deletions(-) create mode 100644 src/main/persistence/applying-settings/legacy-chat-permission-write.ts create mode 100644 src/renderer/src/components/settings/use-chat-permission-settings.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx create mode 100644 tests/e2e/__mocks__/mobile-structured-options.ts rename src/renderer/src/lib/structured-agent-session-launch-admission.test.ts => tests/e2e/structured-agent-session-launch-admission.unit.test.ts (77%) create mode 100644 tests/e2e/structured-permission-report-publication.unit.test.ts diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index 04cfb9c0941..26346fd9d2d 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -3,6 +3,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AccessibilityInfo, Platform } from 'react-native' import { colors } from '../theme/mobile-theme' +import { structuredAgentSessionPermissionDescriptor } from '../../../src/shared/structured-agent-session-permission-option' +import { createStructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' import type { SessionOptionDescriptor, SessionOptionSelectChoice @@ -117,7 +119,8 @@ describe('MobileNativeChatSessionOptionPickers', () => { act(() => { renderer = create(createElement(Pickers, { working: isWorking })) }) - return (working: boolean): void => { + return (working: boolean, nextSnapshot = snapshot): void => { + controller.snapshot = nextSnapshot act(() => renderer!.update(createElement(Pickers, { working }))) } } @@ -347,6 +350,53 @@ describe('MobileNativeChatSessionOptionPickers', () => { expect(rowByText('Sonnet 5').props.accessibilityRole).toBe('radio') }) + it.each([ + ['claude', 'accept-edits', 'Accept edits'], + ['codex', 'full-access', 'Full access'], + ['claude', 'plan', 'Plan mode'], + ['codex', 'read-only', 'Read only'] + ])('uses the nullable %s/%s report without an Own row', async (agent, mode, label) => { + const state = createStructuredAgentSessionOptionState(agent, null) + const snapshot = (reportedPermissionMode?: string | null) => { + const row = structuredAgentSessionPermissionDescriptor({ + ...state, + permissionMode: 'own', + permissionOwnSupported: true, + reportedPermissionMode + }) + return row ? [row] : [] + } + const update = mount(snapshot()) + expect(pill('Permissions').props).toMatchObject({ + disabled: true, + accessibilityLabel: 'Permissions, Permissions' + }) + for (const report of [mode, null, mode, undefined]) { + update(false, snapshot(report)) + expect(pill('Permissions').props).toMatchObject({ + disabled: !report, + accessibilityLabel: `Permissions, ${report ? label : 'Permissions'}` + }) + } + update(false, snapshot(mode)) + await act(async () => pill('Permissions').props.onPress()) + const rows = findByHostType(renderer!.root, 'BottomDrawer').findAll( + (node) => node.props.accessibilityRole === 'radio' + ) + expect( + rows + .filter((row) => row.props.accessibilityState.checked) + .map((row) => findAllByHostType(row, 'Text')[0]?.props.children) + ).toEqual(['accept-edits', 'full-access'].includes(mode) ? [label] : []) + expect( + findAllByHostType(renderer!.root, 'Text').some((node) => + String(node.props.children).includes('own settings') + ) + ).toBe(false) + await act(async () => rowByText('Full access').props.onPress()) + expect(setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access') + }) + it.each([ ['ask', 'Ask for approval', 'Hand', 'full-access', 'Full access'], ['full-access', 'Full access', 'ShieldAlert', 'ask', 'Ask for approval'], diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index fd3435d4ca9..3b70728522e 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -146,17 +146,22 @@ export function MobileNativeChatSessionOptionPickers({ const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason) const permissionChoice = selectedChoice(permission) const permissionColor = permissionChoiceColor(permissionChoice?.value) - const fullAccess = permissionColor !== undefined const permissionIcon = !model && permissionChoice ? permissionModeIcon(permissionChoice.value) : undefined const segmentOptions = options.filter(showsSessionOptionAsSegments) const rowOptions = options.filter((descriptor) => !segmentOptions.includes(descriptor)) - const primary = model ? mobileModelPillLabel(model) : (permissionChoice?.label ?? 'Permissions') + const primary = model + ? mobileModelPillLabel(model) + : permission + ? mobileSessionOptionSummaryValue(permission) + : 'Permissions' const secondary = model && options.length > 0 ? mobileOptionsPillLabel(options) : null const closePicker = (): void => onNavigate(null) const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { + // A permission report can match a new explicit pick while the saved intent is Own. if ( + descriptor !== permission && descriptor.valueSource !== 'unknown' && descriptor.kind.type === 'select' && descriptor.kind.currentValue === value @@ -186,14 +191,14 @@ export function MobileNativeChatSessionOptionPickers({ { onDrawerOpen(home.id) Keyboard.dismiss() @@ -304,7 +309,7 @@ function OptionRows({ {descriptors.map((descriptor, index) => { const isPermission = descriptor === permission - const disabled = isPermission ? busy : locked + const disabled = isPermission ? busy || !descriptor.settable : locked if (descriptor.kind.type === 'boolean' && !descriptor.action) { return ( diff --git a/mobile/src/session/mobile-native-chat-session-option-labels.ts b/mobile/src/session/mobile-native-chat-session-option-labels.ts index 47a857c53bc..b2cb7ba1fea 100644 --- a/mobile/src/session/mobile-native-chat-session-option-labels.ts +++ b/mobile/src/session/mobile-native-chat-session-option-labels.ts @@ -26,6 +26,9 @@ function selectedChoiceLabel(descriptor: SessionOptionDescriptor): string | null ) { return null } + if (descriptor.id === 'permissionMode' && descriptor.description) { + return descriptor.description + } const current = descriptor.kind.currentValue const choice: SessionOptionSelectChoice = descriptor.kind.choices.find( (candidate) => candidate.value === current @@ -44,7 +47,7 @@ export function mobileSessionOptionSummaryValue(descriptor: SessionOptionDescrip return descriptor.kind.currentValue ? 'On' : 'Off' } if (descriptor.valueSource === 'unknown') { - return 'Not set' + return descriptor.id === 'permissionMode' ? 'Permissions' : 'Not set' } return selectedChoiceLabel(descriptor) ?? 'Not set' } diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index b74fd8510f9..ddaaee059c2 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -33,6 +33,7 @@ describe('structuredAgentSessionHostSupport', () => { sendOptions: false, conversationStop: false, permissionModes: false, + permissionOwn: false, itemDetail: false, phoneAttachments: false } diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 5d1f8675b00..d956e9b2f77 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -12,7 +12,10 @@ import { AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' -import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability' +import { + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY +} from '../../../src/shared/agent-session-permission-modes-capability' import { AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-runtime-capabilities' export type StructuredAgentSessionHostSupport = { @@ -35,6 +38,7 @@ export type StructuredAgentSessionHostSupport = { conversationStop: boolean /** The host applies each chat's permission mode; 1.4.225–1.4.226 do without advertising it. */ permissionModes: boolean + permissionOwn?: boolean itemDetail: boolean phoneAttachments: boolean } @@ -56,6 +60,7 @@ export function structuredAgentSessionHostSupport( quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY), sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY), + permissionOwn: capabilities.includes(AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY), permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY), itemDetail: capabilities.includes(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY), phoneAttachments: capabilities.includes(AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY) diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts index 392bb2f1e34..39f441f50c3 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -58,7 +58,7 @@ async function mount(initial: Props) { enabled: true, fence: props.fence, mutate, - hostSupport: { permissionModes: true, coherentModelCatalog: true } + hostSupport: { permissionModes: true, coherentModelCatalog: true, statusFeed: false } }) latest = options frames.push(modelChoices(options.optionSnapshot)) diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index 0cc48e0d094..21982498711 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -50,6 +50,7 @@ type ProbeProps = { optionStamp: string agent?: string hostAdvertisesPermissionModes?: boolean | null + permissionOwn?: boolean } const mutate: StructuredAgentSessionMutate = vi.fn() @@ -68,7 +69,11 @@ async function mountOptions(props: ProbeProps) { hostSupport: probe.hostAdvertisesPermissionModes === null ? null - : { permissionModes: probe.hostAdvertisesPermissionModes ?? true } + : { + permissionModes: probe.hostAdvertisesPermissionModes ?? true, + permissionOwn: probe.permissionOwn, + statusFeed: false + } }) return null } @@ -150,6 +155,42 @@ describe('the permission pill against a host predating per-chat modes', () => { }) describe('the permission mode on the phone', () => { + it('writes only a concrete per-chat choice even when it matches the report for Own', async () => { + vi.mocked(mutate).mockReset() + vi.mocked(mutate).mockResolvedValue({ + status: 'accepted', + value: { options: { permissionMode: 'full-access' } }, + sameFence: true + }) + const client: RpcClient = { + ...optionsClient().client, + sendRequest: async () => ({ + id: 'rpc-1', + ok: true, + result: { + ...optionsFor('own'), + current: { + model: 'gpt-live', + permissionMode: 'own', + reportedPermissionMode: 'full-access' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + const options = await mountOptions({ client, fence: 1, optionStamp: '', permissionOwn: true }) + expect(options.permission()).toBe('full-access') + expect(options.offered()).not.toContain('own') + await options.pick('full-access') + expect(mutate).toHaveBeenCalledExactlyOnceWith( + 'agentSession.setOption', + 'agentSession.setOption', + { key: 'permissionMode', value: 'full-access' } + ) + await options.unmount() + vi.mocked(mutate).mockReset() + }) + 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({ diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 98ffeaec93f..6c62e553f68 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, useLayoutEffect } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' import type { @@ -144,18 +144,17 @@ type ProbeProps = { } function Probe(props: ProbeProps): null { - props.onRender( - useMobileStructuredAgentOptions({ - agent: props.agent, - client: props.client, - sessionId: props.sessionId, - enabled: true, - fence: props.fence, - mutate: props.mutate, - ...(props.optionStamp ? { optionStamp: props.optionStamp } : {}), - hostSupport: { permissionModes: true } - }) - ) + const controller = useMobileStructuredAgentOptions({ + agent: props.agent, + client: props.client, + sessionId: props.sessionId, + enabled: true, + fence: props.fence, + mutate: props.mutate, + ...(props.optionStamp ? { optionStamp: props.optionStamp } : {}), + hostSupport: { permissionModes: true, statusFeed: false } + }) + useLayoutEffect(() => props.onRender(controller), [controller, props.onRender]) return null } diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index c340c7565b9..7bd18db21a4 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -43,7 +43,7 @@ export function useMobileStructuredAgentOptions(args: { optionStamp?: string hostSupport: Pick< StructuredAgentSessionHostSupport, - 'permissionModes' | 'coherentModelCatalog' + 'permissionModes' | 'permissionOwn' | 'coherentModelCatalog' | 'statusFeed' > | null }) { const { agent, client, enabled, fence, mutate, optionStamp, sessionId, hostSupport } = args @@ -56,6 +56,7 @@ export function useMobileStructuredAgentOptions(args: { [sessionId] ) const { + projectPermission, optionState, optionStateRef, activeOptionRecordRef, @@ -72,6 +73,7 @@ export function useMobileStructuredAgentOptions(args: { } = useMobileStructuredOptionState({ agent, enabled, + statusFeed: hostSupport?.statusFeed === true, fence, optionCatalog, sessionId, @@ -111,16 +113,29 @@ export function useMobileStructuredAgentOptions(args: { useLayoutEffect(() => { lockSnapshotRef.current = lockSnapshot }) - const shown = lockSnapshot(structuredAgentSessionShownSnapshot(optionState)) + const shown = lockSnapshot( + structuredAgentSessionShownSnapshot( + projectPermission({ + ...optionState, + permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null + }) + ) + ) const optionSnapshot = legacyHostPermissions ? [...shown, legacyHostPermissionDescriptor()] : shown const setStructuredOption = useCallback( async (id: string, value: SessionOptionValue): Promise => { - const currentState = optionStateRef.current + const currentState = projectPermission({ + ...optionStateRef.current, + permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null + }) const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( + (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID && + value === 'own' && + !hostSupport?.permissionOwn) || !isCurrentContext() || pendingOptionRef.current !== null || !client || @@ -213,6 +228,8 @@ export function useMobileStructuredAgentOptions(args: { [ agent, awaitingOptions, + hostSupport?.permissionOwn, + projectPermission, client, createdHere, mutate, @@ -234,6 +251,7 @@ export function useMobileStructuredAgentOptions(args: { demandModelCatalog, setStructuredOption, optionStateRef, + projectPermission, lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx index e632bbc0a98..62230d28d7a 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx @@ -71,8 +71,13 @@ it('subscribes when an older host refuses the hold, and shows the transcript (P2 onData(SNAPSHOT) return () => undefined }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an RPC client stub with the two members the hook calls. - const client = { sendRequest, subscribe } as unknown as RpcClient + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's request, subscription and connection members are provided. + const client = { + sendRequest, + subscribe, + getState: () => 'connected', + onStateChange: () => () => {} + } as unknown as RpcClient function Harness(): null { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's options, of which this test supplies the ones it reads. hook = useMobileStructuredAgentSession({ diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx index 94c561f0e07..035e12c36b2 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -61,7 +61,9 @@ describe('useMobileStructuredAgentSession transcript', () => { async function mount(agent: string, items: AgentJournalRenderItem[]) { let listener: ((value: unknown) => void) | null = null - const client: Pick = { + const client: Pick = { + getState: () => 'connected', + onStateChange: () => () => {}, sendRequest: vi.fn(async (): Promise => ({ id: 'response-1', ok: true, @@ -78,7 +80,7 @@ describe('useMobileStructuredAgentSession transcript', () => { } function Harness(): null { rendered.hook = useMobileStructuredAgentSession({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's request, subscription and connection members are provided. client: client as RpcClient, sessionId: 'session-1', enabled: true, diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 6535fc204ea..7244ffe680b 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -1,9 +1,13 @@ import { createElement } from 'react' -import { snapshotEvent as publishedSnapshot } from './use-mobile-structured-agent-session-queued.test-fixture' +import { + snapshotEvent as publishedSnapshot, + fieldsOf, + mutationOk, + ok +} from './use-mobile-structured-agent-session-queued.test-fixture' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { - AgentJournalDispatchState, AgentJournalRenderItem, AgentJournalResolution } from '../../../src/shared/agent-session-journal-types' @@ -28,10 +32,6 @@ function listedModelIds(snapshot: readonly SessionOptionDescriptor[]): string[] return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] } -function ok(result: unknown) { - return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } -} - function snapshotEvent(fence = 3, items: AgentJournalRenderItem[] = []) { return publishedSnapshot({ fence, items }) } @@ -121,19 +121,9 @@ function runningStatusItem(): AgentJournalRenderItem { } } -function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) { - return ok({ - ok: true, - replayed: false, - fence: 3, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: structuredSendResultFixture(dispatchState, reason) - }) -} - -async function defaultSendRequest(method: string, params?: Record) { +async function defaultSendRequest(method: string, params?: unknown) { if (method === 'agentSession.send') { - return sendResult('accepted') + return mutationOk(structuredSendResultFixture('accepted')) } if (method === 'agentSession.options') { return ok({ @@ -185,11 +175,11 @@ async function defaultSendRequest(method: string, params?: Record { listener = onData return unsubscribe }) - const client = { + const client: RpcClient = { sendRequest, - subscribe - } as unknown as RpcClient + subscribe, + getState: () => 'connected', + onStateChange: () => () => {}, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + updateTerminalSubscriptionViewport: () => {}, + notifyForeground: () => {}, + close: () => {} + } let storedOperations: Map function Harness({ diff --git a/mobile/src/session/use-mobile-structured-option-state.ts b/mobile/src/session/use-mobile-structured-option-state.ts index fd3a9de2f19..ecb17463749 100644 --- a/mobile/src/session/use-mobile-structured-option-state.ts +++ b/mobile/src/session/use-mobile-structured-option-state.ts @@ -1,3 +1,6 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' +import { projectRuntimePermission } from '../../../src/shared/structured-agent-session-permission-option' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session/agent-session-conversation-command' import type { AgentSessionOptionsResult, @@ -7,7 +10,6 @@ import { callAgentSession } from './mobile-structured-agent-session-rpc' import { forgetMobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { createCoalescingKeyedRunner } from '../../../src/shared/coalescing-keyed-runner' import type { RpcClient } from '../transport/rpc-client' -import { useCallback, useEffect, useRef, useState } from 'react' import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' import { usesInstalledAgentModelCatalog } from '../../../src/shared/agent-model-catalog-ownership' import { cloneStructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state' @@ -27,6 +29,7 @@ export function useMobileStructuredOptionState(args: { coherentCatalog?: boolean agent: string | null enabled: boolean + statusFeed: boolean fence: number | null optionCatalog: AgentSessionOptionCatalog | null sessionId: string | null @@ -34,6 +37,7 @@ export function useMobileStructuredOptionState(args: { const { agent, enabled, + statusFeed, fence, optionCatalog, sessionId, @@ -43,6 +47,19 @@ export function useMobileStructuredOptionState(args: { worktree, coherentCatalog: hostCoherentCatalog = false } = args + const connected = useSyncExternalStore( + client?.onStateChange ?? (() => () => {}), + () => client?.getState() === 'connected' + ) + const feed = + enabled && connected && statusFeed && client && sessionId + ? mobileStructuredSessionStatusFeed(client) + : null + const summary = useSyncExternalStore(feed?.subscribe ?? (() => () => {}), () => + sessionId ? feed?.getSnapshot().get(sessionId) : undefined + ) + const projectPermission = (state: T): T => + projectRuntimePermission(state, summary, fence) const coherentCatalog = usesInstalledAgentModelCatalog(agent ?? '', hostCoherentCatalog) const [optionState, setOptionState] = useState(() => createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) @@ -59,7 +76,7 @@ export function useMobileStructuredOptionState(args: { }, [] ) - const contextKey = JSON.stringify([agent, sessionId, newLaunch, worktree, fence]) + const contextKey = JSON.stringify([agent, sessionId, newLaunch, worktree, fence, connected]) const contextRef = useRef({ key: contextKey, client }) useEffect(() => { contextRef.current = { key: contextKey, client } @@ -83,7 +100,8 @@ export function useMobileStructuredOptionState(args: { ...previous, catalogSource: 'host' as const, record: cloneStructuredAgentSessionOptionRecord(previous.record), - pendingId: null + pendingId: null, + reportedPermissionMode: undefined } : seeded // Keep the chat’s mode visible through provider restarts. @@ -192,6 +210,7 @@ export function useMobileStructuredOptionState(args: { ]) return { + projectPermission, conversationSupport, imageSupport, awaitingOptions, diff --git a/mobile/src/session/use-mobile-structured-option-surface.ts b/mobile/src/session/use-mobile-structured-option-surface.ts index a711c7abc40..6beb0708162 100644 --- a/mobile/src/session/use-mobile-structured-option-surface.ts +++ b/mobile/src/session/use-mobile-structured-option-surface.ts @@ -13,6 +13,7 @@ export function useMobileStructuredOptionSurface(args: { optionSnapshot: SessionOptionDescriptor[] setStructuredOption: (id: string, value: SessionOptionValue) => Promise optionStateRef: RefObject + projectPermission: (state: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState }) { const { optionSnapshot, optionStateRef, setStructuredOption, demandModelCatalog } = args const [optionPickerRequest, setOptionPickerRequest] = useState<{ @@ -35,7 +36,9 @@ export function useMobileStructuredOptionSurface(args: { async (id: string, value: SessionOptionValue) => { await setStructuredOption(id, value) return { - snapshot: args.lockSnapshot(structuredAgentSessionShownSnapshot(optionStateRef.current)) + snapshot: args.lockSnapshot( + structuredAgentSessionShownSnapshot(args.projectPermission(optionStateRef.current)) + ) } }, [args.lockSnapshot, optionStateRef, setStructuredOption] diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 81eebdb3f28..b0822655eb0 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -8,6 +8,7 @@ import type { AgentSessionTurnActivity } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' function journalItem( @@ -29,8 +30,9 @@ describe('useMobileStructuredAgentSession turn indicator', () => { let renderer: ReactTestRenderer | null = null let hook: ReturnType | null = null let listener: ((value: unknown) => void) | null = null - type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } } + type RpcReply = RpcResponse const sendRequest = vi.fn(async (method: string): Promise => ({ + id: 'indicator-rpc', ok: true, result: method === 'agentSession.options' @@ -45,8 +47,19 @@ describe('useMobileStructuredAgentSession turn indicator', () => { listener = onData return vi.fn() }) - const client = { sendRequest, subscribe } as unknown as RpcClient - // Keep callbacks stable so rerenders cannot release the session’s hold/subscription. + const client: RpcClient = { + sendRequest, + subscribe, + getState: () => 'connected', + onStateChange: () => () => {}, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + updateTerminalSubscriptionViewport: () => {}, + notifyForeground: () => {}, + close: () => {} + } + // Stable across renders: a fresh callback would re-run the hold/subscribe effect + // and release the session out from under the test. const onSendError = vi.fn() function Harness(): null { @@ -163,7 +176,7 @@ describe('useMobileStructuredAgentSession turn indicator', () => { await act(async () => { const cancelled = { ok: true, value: { cancelled: true } } - answer({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) + answer({ id: 'indicator-rpc', ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) }) await vi.waitFor(() => expect(hook?.turnIndicator.stopping).toBe(false)) }) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index c48b59dbd08..86132c33c04 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -148,6 +148,9 @@ export async function acquireAcpStructuredSession(input: { onExit: () => input.onExit(session) } ) + if (!spec.permissionModeConfig) { + options.applied.set('permissionMode', launch.fullAccess ? 'full-access' : 'ask') + } // From here, cancellation closes this connection even before exit is proven. input.track(connection) connection.subscribe((event: AcpSessionEvent) => @@ -172,7 +175,7 @@ export async function acquireAcpStructuredSession(input: { sessionId, fence: acquire.fence, acquisitionGeneration: generation, - reportedOptions: options.read().current + reportedOptions: options.observedCurrent(spec.permissionModeConfig?.values) }) } }) diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 4b7b9dd9f1d..0f19cd6ac20 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { vi } from 'vitest' import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { @@ -65,7 +65,7 @@ export type AcpAdapterRig = { signal?: AbortSignal waitForStart?: boolean options?: Readonly> - permissionMode?: AgentChatPermissionMode + permissionMode?: AgentChatPermissionChoice }): ReturnType sent(method: string): FakeFrame[] frame(method: string, index?: number): Promise diff --git a/src/main/acp/acp-structured-antigravity.test.ts b/src/main/acp/acp-structured-antigravity.test.ts index b47f4776fcf..2e86839656f 100644 --- a/src/main/acp/acp-structured-antigravity.test.ts +++ b/src/main/acp/acp-structured-antigravity.test.ts @@ -33,6 +33,14 @@ const result = (frame: AcpFixtureFrame | undefined) => z.record(z.string(), z.unknown()).parse(frame?.message.result) afterEach(closeProviderTimelineRigs) +it('own settings keep the agent’s reported mode without sending a mode config', async () => { + const rig = await openAcpAdapterRig({ spec: ANTIGRAVITY }) + await rig.acquire({ permissionMode: 'own' }) + expect(rig.sent('session/set_config_option')).toHaveLength(0) + const options = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 }) + expect(options.current.reportedPermissionMode).toBe(null) +}) + it('signs a first chat in, asks in default mode, and answers a question with one of its choices', async () => { const [initialized, opened, ...turn] = await readAcpFixture('antigravity-v1-question') const replay = new GrokFixtureReplay(turn) diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index d71e92cac93..c5bf7a88b71 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -129,7 +129,7 @@ export async function initializeAcpStructuredSession( options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) const mode = spec.permissionModeConfig - if (mode) { + if (mode && acquire.permissionMode !== 'own') { // Fail startup rather than silently use a different permission mode. const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask const configId = options.modeConfigId() @@ -193,7 +193,7 @@ export async function initializeAcpStructuredSession( } return { link, - reportedOptions: options.read().current, + reportedOptions: options.observedCurrent(spec.permissionModeConfig?.values), catalogListing: options.startListing() } } diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts index 22c011a96db..3b279890197 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { AcpStructuredOptions } from './acp-structured-options' +import { describe, expect, it, vi } from 'vitest' +import { AcpStructuredOptions, readAcpLiveOptions } from './acp-structured-options' +import { grokModelEfforts } from './acp-dialects/grok-model-catalog' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect' import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' @@ -31,6 +32,78 @@ const configOptions = [ ] describe('ACP session options', () => { + it.each([10, 100])( + 'projects %i live models once while retaining current/report values', + async (count) => { + const modelEfforts = vi.fn(grokModelEfforts) + const options = new AcpStructuredOptions({ ...GROK_ACP_DIALECT, modelEfforts }) + options.adoptSession({ + configOptions: [ + ...configOptions.slice(1), + { + id: 'permission', + name: 'Permission', + category: 'mode', + type: 'select', + currentValue: 'default', + options: [{ value: 'default', name: 'Default' }] + } + ], + models: { + currentModelId: 'model-0', + availableModels: Array.from({ length: count }, (_, index) => ({ + modelId: `model-${index}`, + name: `Model ${index}`, + _meta: { + supportsReasoningEffort: true, + reasoningEfforts: [{ value: 'medium', default: true }] + } + })) + } + }) + const read = vi.spyOn(options, 'read') + const session = { + options, + connection: { + initialize: async () => ({ + protocolVersion: 1, + agentCapabilities: { promptCapabilities: { image: true } } + }) + } + } + for (const mapping of [{ ask: 'default' }, undefined]) { + read.mockClear() + modelEfforts.mockClear() + const result = await readAcpLiveOptions(session, mapping) + expect({ + reads: read.mock.calls.length, + modelProjections: modelEfforts.mock.calls.length + }).toEqual({ + reads: 1, + modelProjections: count + }) + expect(result.current).toEqual({ + model: 'model-0', + effort: 'high', + confirmed: ['model', 'effort'], + reportedPermissionMode: mapping ? 'ask' : null + }) + expect(result.models).toHaveLength(count) + expect( + result.models.every( + (model) => + !model.isDefault && + model.defaultEffort === 'medium' && + model.efforts?.[0]?.value === 'medium' + ) + ).toBe(true) + expect(result.catalogListing.models).toBe(result.models) + expect(result.catalogListing.configuredDefault).toBeUndefined() + expect(result.acceptsImages).toBe(true) + } + } + ) + it('keeps the session’s picks out of the catalog facts', () => { // OpenCode refuses an effort the selected model lacks, so another model's empty menu stays strict. const options = new AcpStructuredOptions(OPENCODE_ACP_DIALECT) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index f262044e99d..1338487eb2d 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -63,7 +63,7 @@ export class AcpStructuredOptions { // Only new sessions resolve configured defaults; loaded sessions retain their saved choices. private resolvesConfig = false readonly picked = new Set() - /** Last successful Orca writes, distinct from agent-reported values. */ + /** Installed Orca policy and successful writes, distinct from native agent reports. */ readonly applied = new Map() private initialChoice: Readonly> = {} currentProof = new Set() @@ -209,6 +209,22 @@ export class AcpStructuredOptions { } } + observedCurrent(values?: Readonly>>) { + return { ...this.read().current, reportedPermissionMode: this.reportedPermissionMode(values) } + } + + reportedPermissionMode( + values: Readonly>> | undefined + ): string | null { + if (!values) { + return this.applied.get('permissionMode') ?? null + } + const current = this.select('mode')?.currentValue + return current + ? (Object.entries(values).find(([, value]) => value === current)?.[0] ?? null) + : null + } + // Match the mode category rather than assuming an agent's config ID. modeConfigId(): string { return this.select('mode')?.id ?? 'mode' @@ -231,11 +247,20 @@ export async function acpSessionAcceptsImages(session: { } export async function readAcpLiveOptions( - session: Parameters[0] & { options: AcpStructuredOptions } + session: Parameters[0] & { options: AcpStructuredOptions }, + permissionModes?: Readonly>> ) { const acceptsImages = await acpSessionAcceptsImages(session) // Only startup establishes config resolution; later reads may reflect changed picks. - return { ...withLiveCatalogListing(session.options.read()), acceptsImages } + const options = withLiveCatalogListing(session.options.read()) + return { + ...options, + current: { + ...options.current, + reportedPermissionMode: session.options.reportedPermissionMode(permissionModes) + }, + acceptsImages + } } /** Only the current, still-awaited write may establish reusable option proof. */ diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index 1ff02a26492..da74d6cf785 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -1,3 +1,4 @@ +import { acpLaunchSpecFor } from './acp-launch-specs' import { z } from 'zod' import { afterEach, describe, expect, it } from 'vitest' import { @@ -16,6 +17,51 @@ afterEach(closeProviderTimelineRigs) const configParams = z.object({ configId: z.string(), value: z.string() }) describe('ACP child published before its handshake', () => { + it('publishes mapped initial/config permission without selecting it, and generic unknown for legacy modes', async () => { + const mode = { + id: 'mode', + name: 'Mode', + category: 'mode', + type: 'select', + currentValue: 'default', + options: [ + { value: 'default', name: 'Default' }, + { value: 'yolo', name: 'Yolo' } + ] + } + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('antigravity')!, + script: (agent) => + agent.on('session/new', (frame) => + agent.reply(frame, { + sessionId: PROVIDER_SESSION, + configOptions: [...GROK_CONFIG_OPTIONS, mode] + }) + ) + }) + await rig.acquire({ permissionMode: 'own' }) + expect(rig.started().reportedOptions).toMatchObject({ reportedPermissionMode: 'ask' }) + for (const [currentValue, expected] of [ + ['yolo', 'full-access'], + ['unknown', null] + ] as const) { + rig.child().agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'config_option_update', + configOptions: [...GROK_CONFIG_OPTIONS, { ...mode, currentValue }] + } + }) + await waitFor(() => + expect(rig.lifecycle.at(-1)).toMatchObject({ + type: 'options-reported', + reportedOptions: { reportedPermissionMode: expected } + }) + ) + } + expect(rig.sent('session/set_config_option')).toHaveLength(0) + }) + it('opens without writing saved message choices to the provider', async () => { let answer: (() => void) | undefined const writes: string[] = [] diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 82a3b11da85..aa53620374c 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -10,7 +10,7 @@ import type { AcpLaunchSpec } from './acp-launch-specs' import type { ConnectAcpAgent } from './acp-structured-connection' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' // Grace begins at cancellation, before process close. export const ACP_STOP_GRACE_MS = 4_000 @@ -23,7 +23,7 @@ export type AcpStructuredSessionAdapterDeps = { identity: AgentSessionJournalIdentity spawnToken: string /** Defaults to ask. */ - permissionMode?: AgentChatPermissionMode + permissionMode?: AgentChatPermissionChoice }) => Promise /** The same launch for a read that starts no child (a conversation copy): the agent's env, unsealed. */ readLaunch: (identity: AgentSessionJournalIdentity) => Promise diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index c3255c18604..779ac73b57d 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -251,8 +251,10 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return session.options.reported() } - readOptions = async (input: { sessionId: string; fence: number }) => - readAcpLiveOptions(this.live(input.sessionId)) + readOptions = async (input: { sessionId: string; fence: number }) => { + const session = this.live(input.sessionId) + return readAcpLiveOptions(session, session.spec.permissionModeConfig?.values) + } readCommands = (sessionId: string) => { const session = this.children.sessions.get(sessionId) 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 f74032ec64e..84c24607a46 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -13,7 +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 type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' @@ -124,7 +124,7 @@ function recordingSpawner(spawns: SpawnSeen[]) { } } -function resolvedLaunch(permissionMode: AgentChatPermissionMode, launchArgs: string[] = []) { +function resolvedLaunch(permissionMode: AgentChatPermissionChoice, launchArgs: string[] = []) { const record = { sessionId: 'contract-pin-session', provider: 'claude', @@ -316,6 +316,26 @@ describe('Claude Agent SDK contract pins', () => { expect(replayArgs.filter((arg) => arg === '--replay-user-messages')).toHaveLength(1) }) + it('leaves own-settings permission resolution to the CLI in the real SDK child argv', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const spawns: SpawnSeen[] = [] + const launch = await resolvedLaunch('own') + await drainQuery({ + ...launch.options, + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + canUseTool: async () => ({ behavior: 'deny', message: 'unused' }), + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + expect(spawns).toHaveLength(1) + const argv = normalizeArgv(spawns[0]!.args) + expect(argv).not.toContain('--permission-mode') + expect(argv).not.toContain('--dangerously-skip-permissions') + expect(launch.options.permissionMode).toBeUndefined() + }) + it('produces a matching CLI flag for every pre-SDK argv entry', async () => { const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) const spawns: SpawnSeen[] = [] diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index eed7a2062af..a746759ec44 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -1,6 +1,6 @@ import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' -import type { ClaudeAuthDiagnostic } from './claude-structured-session-state' +import type { ClaudeAuthDiagnostic, ClaudeSession } from './claude-structured-session-state' import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export type ClaudeInitObservation = { @@ -41,6 +41,46 @@ export function readClaudeInit(message: Record): ClaudeInitObse : null } +/** Init and status frames describe the running mode; absent fields preserve the last report. */ +export function readClaudeReportedPermissionMode( + message: Record +): string | null | undefined { + if ( + message.type !== 'system' || + (message.subtype !== 'init' && message.subtype !== 'status') || + message.permissionMode === undefined + ) { + return undefined + } + switch (message.permissionMode) { + case 'default': + return 'ask' + case 'acceptEdits': + return 'accept-edits' + case 'auto': + return 'auto' + case 'plan': + return 'plan' + case 'bypassPermissions': + return 'full-access' + case null: + default: + return null + } +} + +export function observeClaudeReportedPermissionMode( + reports: ClaudeSession['reportedOptions'], + message: Record +): boolean { + const mode = readClaudeReportedPermissionMode(message) + if (mode === undefined || mode === reports.reportedPermissionMode) { + return false + } + reports.reportedPermissionMode = mode + return true +} + export function readClaudeModels(initialization: unknown): unknown[] { return isRecord(initialization) && Array.isArray(initialization.models) ? initialization.models diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 03fef1e185f..52ff0daf405 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -5,7 +5,10 @@ import type { Options as ClaudeAgentSdkOptions, PermissionMode } from '@anthropic-ai/claude-agent-sdk' -import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import type { + AgentChatPermissionChoice, + AgentChatPermissionMode +} from '../../shared/agent-chat-permission-mode' import { agentSessionProviderHandleKey, agentSessionProviderHandleRoot @@ -39,25 +42,28 @@ export { claudeSessionIdForOrcaSession } export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' +type ClaudePermissionOptions = Pick & { + resolvePermissionModeInCli?: true +} + export type ClaudeStructuredSdkOptions = Pick< ClaudeAgentSdkOptions, | 'includePartialMessages' | 'systemPrompt' | 'settingSources' | 'supportedDialogKinds' - | 'extraArgs' | 'additionalDirectories' | 'plugins' | 'model' | 'effort' - | 'permissionMode' | 'allowDangerouslySkipPermissions' | 'settings' | 'sessionId' | 'resume' | 'resumeSessionAt' | 'forkSession' -> +> & + ClaudePermissionOptions /** Replay acknowledgements require an explicit flag; the SDK supplies the other transport flags. */ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { @@ -78,8 +84,12 @@ const CLAUDE_PERMISSION_MODES = { /** Full access is the owned CLI flag: the SDK's typed bypass emits a flag older binaries reject. */ export function claudeStructuredPermissionOptions( - mode: AgentChatPermissionMode -): Pick { + mode: AgentChatPermissionChoice +): ClaudePermissionOptions { + if (mode === 'own') { + // upstream request: https://github.com/anthropics/claude-agent-sdk-typescript/issues/519 + return { resolvePermissionModeInCli: true } + } return mode === 'full-access' ? { extraArgs: { 'dangerously-skip-permissions': null } } : { permissionMode: CLAUDE_PERMISSION_MODES[mode] } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index f29d9ce7064..4255429b458 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,7 +1,4 @@ -import type { - AgentSessionAcquisition, - StructuredAgentSessionAcquireInput -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' @@ -11,6 +8,7 @@ import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' import { createClaudeInitProof, readClaudeCapabilities, + observeClaudeReportedPermissionMode, readClaudeFrameString, readClaudeInit } from './claude-structured-init-proof' @@ -23,11 +21,8 @@ import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { createClaudeSessionPublication } from './claude-structured-session-publication' import { mintClaudeAcquisitionGeneration, - type ClaudeAcquisitionRegistry, type ClaudeSession, - type ClaudeSessionExit, - type ClaudeStructuredSessionAdapterDeps, - type ClaudeAcquireCallbacks, + type ClaudeSessionAcquisitionInput, type ClaudeLateDispatchOutcome } from './claude-structured-session-state' import { resolveClaudeAcquisitionError } from './claude-structured-session-close' @@ -48,14 +43,7 @@ export async function acquireClaudeSession({ acquisitions, exits, callbacks -}: { - input: StructuredAgentSessionAcquireInput - deps: ClaudeStructuredSessionAdapterDeps - sessions: Map - acquisitions: ClaudeAcquisitionRegistry - exits: Map - callbacks: ClaudeAcquireCallbacks -}): Promise { +}: ClaudeSessionAcquisitionInput): Promise { const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() const { previous, attempt } = acquisitions.start(sessionId, prompts) @@ -82,6 +70,7 @@ export async function acquireClaudeSession({ ) const contextActivity = translator?.contextActivity ?? 0 const recordTurnEnd = claudeTurnResumePointWriter(sessionId, sessions, deps) + const observedOptions: ClaudeSession['reportedOptions'] = {} const onMessage = (message: Record): void => { const init = readClaudeInit(message) if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { @@ -91,6 +80,7 @@ export async function acquireClaudeSession({ } return } + const permissionChanged = observeClaudeReportedPermissionMode(observedOptions, message) const modelChanged = liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false if (init) { @@ -130,13 +120,16 @@ export async function acquireClaudeSession({ } try { callbacks.deliver(attempt, sessionId, () => { - if (modelChanged && liveSession && init?.model) { + if (liveSession && (modelChanged || permissionChanged)) { callbacks.emit(liveSession, input.events, { type: 'options-reported', sessionId, fence: liveSession.fence, acquisitionGeneration: liveSession.acquisitionGeneration, - reportedOptions: { model: init.model } + reportedOptions: { + ...(modelChanged && init?.model ? { model: init.model } : {}), + reportedPermissionMode: observedOptions.reportedPermissionMode + } }) } callbacks.emit(liveSession, input.events, { @@ -278,6 +271,7 @@ export async function acquireClaudeSession({ observedAt: deps.now?.() ?? Date.now() }) const session = publication.session + session.reportedOptions = observedOptions liveSession = session adoptClaudeStructuredSpawnOptions(session, launch.savedOptions) acquisitions.deleteIfCurrent(sessionId, attempt) diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index afe2eced09d..92a5f87e776 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -259,6 +259,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { expect(options.current).toEqual({ model: 'claude-sonnet-5', effort: 'high', + reportedPermissionMode: undefined, confirmed: ['model', 'effort'] }) expect(options.models.some((model) => model.label === 'Default')).toBe(false) @@ -293,6 +294,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { expect(result.current).toEqual({ model: 'custom-model', effort: 'high', + reportedPermissionMode: undefined, confirmed: ['model', 'effort'] }) }) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 341a02d440e..d129969f5e8 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -192,6 +192,7 @@ export function claudeStructuredSessionOptionsFrom( ...(support ? { fastModeSupport: support } : {}), current: { model, + reportedPermissionMode: session.reportedOptions.reportedPermissionMode, ...(effort ? { effort } : {}), ...(fastMode !== undefined ? { fastMode } : {}), ...(session.fastModeState ? { fastModeState: session.fastModeState } : {}), diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index c0e9077f0e6..f0e15ab71c3 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { adapterAtPublishFor, fakeClaude, + claudeFrame, identityFor, claudeOpened, USER_MESSAGE @@ -21,6 +22,96 @@ describe('Claude host-awaited open', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) + // SDK-typed shape; ordering constructed + it.each([ + ['bypassPermissions', 'full-access'], + ['plan', 'plan'], + [null, null], + [undefined, 'accept-edits'] + ] as const)( + 'preserves the startup status permission report %s over init', + async (mode, expected) => { + const claude = fakeClaude() + const adapter = adapterAtPublishFor({ + ...claude, + openConnection: async (...args) => { + const connection = await claude.openConnection(...args) + const observed = claude.connections[0] + if (!observed) { + throw new Error('missing test connection') + } + claudeFrame(observed, { + type: 'system', + subtype: 'init', + permissionMode: 'acceptEdits', + model: 'claude-sonnet-5' + }) + claudeFrame(observed, { + type: 'system', + subtype: 'status', + status: null, + ...(mode === undefined ? {} : { permissionMode: mode }) + }) + return connection + } + }) + await adapter.acquire({ ...ACQUIRE, options: { permissionMode: 'own' } }) + const opened = await claudeOpened(adapter, 'session-1') + expect(opened.reportedOptions?.reportedPermissionMode).toBe(expected) + expect( + (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current + .reportedPermissionMode + ).toBe(expected) + const connection = claude.connections[0] + if (!connection) { + throw new Error('missing test connection') + } + expect(connection.launch.options.permissionMode).toBeUndefined() + expect(connection.calls.some((call) => call.subtype === 'set_permission_mode')).toBe(false) + await adapter.closeAll() + } + ) + + it('refreshes only present owned permission reports through the existing options signal', async () => { + const claude = fakeClaude() + const { adapter, events } = adapterFor(claude) + await adapter.acquire({ ...ACQUIRE, options: { permissionMode: 'own' } }) + await claudeOpened(adapter, 'session-1') + const connection = claude.connections[0] + if (!connection) { + throw new Error('missing test connection') + } + for (const [frame, expected, changed] of [ + [ + { subtype: 'init', permissionMode: 'acceptEdits', model: 'claude-haiku-4-5' }, + 'accept-edits', + true + ], + [{ subtype: 'status', status: null, permissionMode: 'default' }, 'ask', true], + [{ subtype: 'status', status: 'compacting' }, 'ask', false], + [{ subtype: 'init', model: 'claude-haiku-4-5' }, 'ask', false], + [{ subtype: 'status', status: null, permissionMode: 'plan' }, 'plan', true], + [{ subtype: 'status', status: null, permissionMode: null }, null, true], + [ + { subtype: 'status', permissionMode: 'bypassPermissions', session_id: 'foreign' }, + null, + false + ] + ] as const) { + const before = events.filter((event) => event.type === 'options-reported').length + claudeFrame(connection, { type: 'system', ...frame }) + expect( + (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current + .reportedPermissionMode + ).toBe(expected) + expect(events.filter((event) => event.type === 'options-reported').length - before).toBe( + changed ? 1 : 0 + ) + } + expect(connection.calls.some((call) => call.subtype === 'set_permission_mode')).toBe(false) + await adapter.closeAll() + }) + it('publishes the owned process as starting before initialize answers', async () => { const claude = fakeClaude({ initDelayMs: 12_000 }) const { adapter, events } = adapterFor(claude) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 8d960988e62..7f97476bfd4 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -9,6 +9,7 @@ import { claudeAuthDiagnostic, claudeInitializationAuthError, readClaudeCapabilities, + readClaudeReportedPermissionMode, readClaudeModels, type ClaudeInitObservation, type ClaudeInitProof @@ -113,6 +114,10 @@ export async function readClaudeStartupFacts(input: { } function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitializeFacts): void { + const permission = facts.init ? readClaudeReportedPermissionMode(facts.init.message) : undefined + if (permission !== undefined && session.reportedOptions.reportedPermissionMode === undefined) { + session.reportedOptions.reportedPermissionMode = permission + } const { init, initialization } = facts // Preserve a running model already reported by a turn's init frame. if (init?.model && session.reportedOptions.model === undefined) { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 16e2d878d43..201d32a001c 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -4,7 +4,10 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-f import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' 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 } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionOptionsReportedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -159,7 +162,12 @@ export type ClaudeSession = { /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ replayContentFallbackBlocked: boolean options: Map - reportedOptions: { model?: string; effort?: string; fastMode?: boolean } + reportedOptions: { + model?: string + effort?: string + fastMode?: boolean + reportedPermissionMode?: string | null + } /** CLI-resolved env/settings for the next request; lower priority than held options and turn reports. */ appliedOptions?: { model?: string; effort?: string } /** CLI settings resolved before a turn changed them, when no launch override hid that value. */ @@ -343,6 +351,15 @@ export async function cancelClaudeAcquisitionAttempt( }) } +export type ClaudeSessionAcquisitionInput = { + input: StructuredAgentSessionAcquireInput + deps: ClaudeStructuredSessionAdapterDeps + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + callbacks: ClaudeAcquireCallbacks +} + export type ClaudeAcquireCallbacks = { deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void emit: ( diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 16687aef0eb..30de6dfb17c 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -272,6 +272,15 @@ describe('codex structured launch resolution', () => { expect(launch.permissionPolicy).toEqual(policy) }) + it('adds no policy overrides for the agent’s own settings', async () => { + const launch = await resolverFor(record())({ + identity: IDENTITY, + spawnToken: TEST_SPAWN_TOKEN, + permissionMode: 'own' + }) + expect(launch).not.toHaveProperty('permissionPolicy') + }) + it('ignores the bypass flag the agent Arguments carry', async () => { const launch = await resolverFor(record(), undefined, () => [ '--dangerously-bypass-approvals-and-sandbox' diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 5a6ffd87f25..62ad8138f8b 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -87,8 +87,10 @@ export function codexStructuredLaunchPart( resumeThreadId, // Only created threads may be unsaved; resumed, forked and adopted threads were persisted. ...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}), - // Omitted policy fields would inherit the resumed thread's previous permissions. - permissionPolicy: codexStructuredPermissionPolicy(basis.permissionMode ?? 'ask'), + // Own settings leaves a new thread to config and a resumed thread to its saved policy. + ...(basis.permissionMode === 'own' + ? {} + : { permissionPolicy: codexStructuredPermissionPolicy(basis.permissionMode ?? 'ask') }), ...(model ? { model } : {}), ...(visuals ? { visuals } : {}) }, diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index a01d845b6b1..6b26fcb8288 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -113,6 +113,7 @@ describe('Codex structured service tier without send-path catalog waits', () => expect(codex.connections[0].calls.some((call) => call.method === 'thread/resume')).toBe(true) expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-next', + reportedPermissionMode: null, serviceTier: 'ultrafast' }) await send(adapter, 'first') diff --git a/src/main/codex/codex-structured-service-tier.ts b/src/main/codex/codex-structured-service-tier.ts index 07debc72c39..686a73485a9 100644 --- a/src/main/codex/codex-structured-service-tier.ts +++ b/src/main/codex/codex-structured-service-tier.ts @@ -75,6 +75,7 @@ export function reportedCodexThreadOptions( opened: CodexOpenedThread ): CodexSession['reportedOptions'] { return { + reportedPermissionMode: opened.reportedPermissionMode, ...(opened.model ? { model: opened.model } : {}), ...(opened.effort ? { effort: opened.effort } : {}), ...('serviceTier' in opened diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index dfaa168d6a0..50e5bce64d6 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -39,7 +39,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-saved', effort: 'low', - serviceTier: 'priority' + serviceTier: 'priority', + reportedPermissionMode: null }) expect(codex.connections[0].calls.map((call) => call.method)).toEqual([ 'initialize', @@ -106,10 +107,11 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-live', - effort: 'medium' + effort: 'medium', + reportedPermissionMode: null }) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ - current: { model: 'gpt-live', effort: 'medium' }, + current: { model: 'gpt-live', effort: 'medium', reportedPermissionMode: null }, models: [ { id: 'gpt-live', diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index 98eec5d3122..c87dad1b06c 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -39,6 +39,7 @@ export function reportedCodexSessionOptions( const serviceTier = session.options.get('serviceTier') return { model, + reportedPermissionMode: session.reportedOptions.reportedPermissionMode, ...(effort ? { effort } : {}), ...(serviceTier ? { serviceTier } : {}) } @@ -78,7 +79,14 @@ function composeLiveCodexCatalog( } : {}) }) - return { ...catalog, fastModeSupport: listing.fastModeSupport } + return { + ...catalog, + current: { + ...catalog.current, + reportedPermissionMode: session.reportedOptions.reportedPermissionMode + }, + fastModeSupport: listing.fastModeSupport + } } export async function readLiveCodexSessionOptions( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 3ccc4fd9d77..3e8deea7e6c 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -152,6 +152,7 @@ export type CodexSession = { prompts: CodexAcquisitionWindow['prompts'] options: Map reportedOptions: { + reportedPermissionMode?: string | null model?: string effort?: string serviceTier?: string | null diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index 7c9c1bc01d2..f542f1bc734 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -14,6 +14,39 @@ function connectionFor( } describe('openCodexThread', () => { + it.each([ + ['workspaceWrite', 'auto'], + ['readOnly', 'read-only'], + ['read-only', 'read-only'] + ])('leaves own policy omitted on start/resume and reports %s', async (sandboxType, expected) => { + const request = vi.fn(async () => ({ + thread: { id: 'own-thread' }, + approvalPolicy: 'on-request', + sandbox: { type: sandboxType }, + approvalsReviewer: 'auto_review' + })) + for (const resumeThreadId of [null, 'own-thread']) { + const opened = await openCodexThread( + connectionFor(request), + { cwd: '/workspace', resumeThreadId }, + 2_000 + ) + expect(opened.reportedPermissionMode).toBe(expected) + } + expect(request).toHaveBeenNthCalledWith( + 1, + 'thread/start', + { cwd: '/workspace' }, + { timeoutMs: 2_000 } + ) + expect(request).toHaveBeenNthCalledWith( + 2, + 'thread/resume', + { cwd: '/workspace', threadId: 'own-thread', excludeTurns: true }, + { timeoutMs: 2_000 } + ) + }) + it('applies the resolved permission policy when starting and resuming a thread', async () => { const request = vi.fn(async (method: string) => ({ thread: { id: method === 'thread/start' ? 'thread-created' : 'thread-existing' } @@ -97,6 +130,7 @@ describe('openCodexThread', () => { openCodexThread(connectionFor(standard), { cwd: '/workspace', resumeThreadId: null }, 2_000) ).resolves.toEqual({ threadId: 'thread-standard', + reportedPermissionMode: null, thread: { id: 'thread-standard' }, serviceTier: null }) diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index edc57c33dad..2cb43aa976c 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -8,6 +8,7 @@ import { readCodexThreadId } from './codex-structured-thread-facts' export type CodexOpenedThread = { threadId: string + reportedPermissionMode?: string | null supersededThreadId?: string thread?: Record historyMode?: 'legacy' | 'paginated' @@ -130,6 +131,7 @@ export async function openCodexThread( const serviceTier = nonEmptyString(result.serviceTier) return { threadId, + reportedPermissionMode: reportedCodexPermissionMode(result), ...(supersededThreadId === undefined ? {} : { supersededThreadId }), thread, ...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated' @@ -140,3 +142,28 @@ export async function openCodexThread( ...(serviceTierKnown ? { serviceTier } : {}) } } + +/** Unrecognized policies remain the agent's settings rather than a guessed Orca mode. */ +function reportedCodexPermissionMode(reply: Record): string | null { + const approval = reply.approvalPolicy ?? reply.approval_policy + const reviewer = reply.approvalsReviewer ?? reply.approvals_reviewer + const sandbox = reply.sandbox + const sandboxType = + typeof sandbox === 'object' && sandbox !== null && 'type' in sandbox ? sandbox.type : sandbox + if (sandboxType === 'readOnly' || sandboxType === 'read-only') { + return 'read-only' + } + if ( + (sandboxType === 'dangerFullAccess' || sandboxType === 'danger-full-access') && + approval === 'never' + ) { + return 'full-access' + } + if ( + (sandboxType === 'workspaceWrite' || sandboxType === 'workspace-write') && + approval === 'on-request' + ) { + return reviewer === 'auto_review' ? 'auto' : reviewer === 'user' ? 'ask' : null + } + return null +} 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 5b8a718ef95..b159505f97d 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 @@ -16,7 +16,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 type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import { admitAttachOrRefuse, attachJournal, @@ -81,7 +81,7 @@ export type AttachFlowInput = { startupOptions?: Readonly> launchPermissionMode?: ( record: AgentSessionRecord - ) => Promise + ) => Promise /** Publish journal before new-owner sends; distinguish newly starting, retained live and resting owners. */ onAttached: ( attached: AttachedJournal, 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 ab3b8834980..bd82eeadcfa 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 @@ -29,7 +29,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' +import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' export type StructuredAgentSessionCaller = { callerKey: string } @@ -69,7 +69,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** Immutable, memory-only opening send ID; distinguishes its restart from another start. */ readonly startedFor?: string /** Launch-time permission mode, memory-only. */ - readonly permissionMode?: AgentChatPermissionMode + readonly permissionMode?: AgentChatPermissionChoice /** Read adapter-held sends at each decision. */ readonly heldSends?: () => ReadonlySet | undefined readonly exitObservation?: DeferredStructuredAgentSessionEventSink['exitObservation'] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index 83edf094be1..b3f858d82ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -4,7 +4,7 @@ import { chatModelSupportsAutoMode, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, - type AgentChatPermissionMode + type AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -33,7 +33,7 @@ async function listedAutoModeSupport( export async function structuredAgentSessionLaunchPermission( deps: Pick, record: AgentSessionRecord -): Promise { +): Promise { const support = agentChatPermissionModes(record.provider) if (!support || !deps.agents.definition(record.provider)) { return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts index ac69e4bad15..ba3b211fd23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts @@ -23,7 +23,9 @@ function acceptsOptionPick( const definition = agents.definition(provider) return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ? definition !== null && - agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true + agentChatPermissionModes(provider) !== null && + (value === 'own' || + agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true) : definition?.restingOptions.acceptsKey(key) === true } 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 48057f3cbd0..fd431550ab0 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 @@ -206,7 +206,7 @@ export async function readStructuredAgentSessionOptions( const capabilities = agents.capabilities(agent) const floor = session.journal.context.floor() const contextFloor = floor ? { contextFloor: floor } : {} - // Derive launch mode consistently; children never report it. + // Intent and the live agent report stay separate: own never claims a mode before startup. const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null @@ -289,7 +289,10 @@ export async function readStructuredAgentSessionOptions( confirmed, ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), ...(confirmed?.length ? { confirmed } : {}), - ...(permissionMode ? { permissionMode } : {}) + ...(permissionMode ? { permissionMode } : {}), + ...(live && options.current.reportedPermissionMode !== undefined + ? { reportedPermissionMode: options.current.reportedPermissionMode } + : { reportedPermissionMode: undefined }) }, rewind: phase === 'prepared' || phase === 'provider-succeeded' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 43d5df12502..ea51d69eb64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -16,7 +16,7 @@ import { } 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 { AgentChatPermissionChoice } 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 { @@ -188,7 +188,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { input: Parameters[0], work: Partial = {}, stopAgent = vi.fn(async () => undefined), - launchPermission?: () => Promise + launchPermission?: () => Promise ) => { const warn = vi.fn() await retireOutgrownStructuredAgentSessionChild('session', conversation(input), { @@ -250,7 +250,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { }) describe('a child launched with another permission mode', () => { - const now = (mode: AgentChatPermissionMode) => vi.fn(async () => mode) + const now = (mode: AgentChatPermissionChoice) => vi.fn(async () => mode) const askChild = { child: childAt({ permissionMode: 'ask' }) } it('is stopped once, and one launched with the current mode is kept', async () => { @@ -313,8 +313,8 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { ) } const stoppedAfterLaunch = async ( - launched: AgentChatPermissionMode, - current: () => Promise + launched: AgentChatPermissionChoice, + current: () => Promise ) => (await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current)) .stopAgent.mock.calls.length > 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 9adebb9afbf..aa2729d702a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -11,7 +11,7 @@ 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' +import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' type OutgrownChildReading = Pick & { journal: Pick & { @@ -68,7 +68,7 @@ function owesWakeUp( /** Compare the actual launch mode so replacement cannot loop. */ async function launchedWithOtherPermissionMode( child: OutgrownChildReading['child'], - current: (() => Promise) | undefined + current: (() => Promise) | undefined ): Promise { if (!child || child.phase === 'starting' || child.close || !child.permissionMode || !current) { return false @@ -86,7 +86,7 @@ export async function retireOutgrownStructuredAgentSessionChild( work: StructuredAgentSessionChildWorkReads & { childWork: () => readonly AgentChildWorkView[] | undefined } - launchPermission?: () => Promise + launchPermission?: () => Promise stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index f49288e9717..ca0a18e46c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -15,7 +15,10 @@ import { saveStructuredAgentSessionStartReadbackListing } from './structured-agent-session-start-listing' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' -import { noteStructuredAgentSessionReportedOptions } from './structured-agent-session-ran-options' +import { + noteStructuredAgentSessionRuntimePermission, + noteStructuredAgentSessionReportedOptions +} from './structured-agent-session-ran-options' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session/ownership/agent-session-provider-handle-transition' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -82,6 +85,8 @@ export async function settleStructuredAgentSessionProviderStarted( return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) + noteStructuredAgentSessionRuntimePermission(session.child, event.reportedOptions, true) + context.publishStatus?.(event.sessionId) await noteStructuredAgentSessionReportedOptions( context, event.sessionId, @@ -221,7 +226,14 @@ export async function settleStructuredAgentSessionOptionsReported( event: StructuredAgentSessionOptionsReportedEvent ): Promise { const child = context.sessions.get(event.sessionId)?.child - if (child?.phase === 'starting') { + if ( + !child || + !sameProviderChild(child, { generation: event.acquisitionGeneration, fence: event.fence }) + ) { + return + } + noteStructuredAgentSessionRuntimePermission(child, event.reportedOptions) + if (child.phase === 'starting') { const readiness = context.runtimeState.startupAttempts.readiness(event.sessionId, { generation: event.acquisitionGeneration, fence: event.fence @@ -230,6 +242,10 @@ export async function settleStructuredAgentSessionOptionsReported( return } } + if (context.sessions.get(event.sessionId)?.child !== child) { + return + } + context.publishStatus?.(event.sessionId) await noteStructuredAgentSessionReportedOptions( context, event.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index 1e6dce0f459..37b1a5956f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -27,7 +27,7 @@ export type StructuredAgentSessionHandedOptions = { /** Per-child reports and a handed-over turn awaiting its first model evidence. */ const childRuns = new WeakMap< StructuredAgentSessionProviderChild, - { reported?: RanOptions; owed?: StructuredAgentSessionHandedOptions } + { reported?: RanOptions; owed?: StructuredAgentSessionHandedOptions; permission?: string | null } >() function currentChild(context: RanOptionsContext, sessionId: string, fence: number) { @@ -41,6 +41,28 @@ export function structuredAgentSessionChildRan( return childRuns.get(child)?.reported } +/** The current child's descriptive report; never a saved or applied permission. */ +export function structuredAgentSessionRuntimePermission( + child: StructuredAgentSessionProviderChild | undefined +): { fence: number; mode: string | null } | null { + const mode = child && childRuns.get(child)?.permission + return child?.phase === 'ready' && mode !== undefined ? { fence: child.fence, mode } : null +} + +export function noteStructuredAgentSessionRuntimePermission( + child: StructuredAgentSessionProviderChild, + reported: AgentSessionOptionsResult['current'], + initial = false +): void { + const previous = childRuns.get(child) + if ( + reported.reportedPermissionMode !== undefined && + (!initial || previous?.permission === undefined) + ) { + childRuns.set(child, { ...previous, permission: reported.reportedPermissionMode }) + } +} + function reportedRan(reported: AgentSessionOptionsResult['current']): RanOptions | null { const { model, effort, serviceTier, fastMode, fastModeState } = reported const filled = reported.defaulted ?? [] @@ -69,7 +91,7 @@ export async function noteStructuredAgentSessionReportedOptions( return } const runs = childRuns.get(child) - childRuns.set(child, { reported: ran }) + childRuns.set(child, { ...runs, reported: ran }) if (runs?.owed && ran.model) { const owed = runs.owed await recordStructuredAgentSessionRanOptions(context, sessionId, fence, { @@ -132,7 +154,7 @@ export async function recordStructuredAgentSessionRanOptions( const model = executed ? (executed.model ?? message?.model ?? newerModel) : (message?.model ?? reported?.model) - childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: handed }) }) + childRuns.set(child, { ...childRuns.get(child), owed: model ? undefined : handed }) if (!model) { return } 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 a1eec710ac9..30961d720aa 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 @@ -8,7 +8,7 @@ import type { } 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' +import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' /** Absolute registration-time ceiling, independent of shorter protocol-step bounds. */ export const STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS = 10 * 60_000 @@ -29,7 +29,8 @@ export type StructuredAgentSessionStartupAttempt = { readonly launch: Pick /** Saved launch intent, never catalog guesses. */ readonly options?: Readonly> - readonly permissionMode?: AgentChatPermissionMode + readonly permissionMode?: AgentChatPermissionChoice + /** Provider events may begin before spawn returns. */ readonly events?: StructuredAgentSessionEventSink /** Attempt-scoped background evidence; adapters use registration reporting when absent. */ readonly childWork?: (evidence: AgentChildWorkEvidence[]) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index c5aba3c82fd..059ac09eaf5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -93,7 +93,9 @@ async function startHost(): Promise { adapter: { open: ({ acquisitionGeneration }) => new Promise((resolve) => { - openGates.set(acquisitionGeneration!, (model) => resolve({ reportedOptions: { model } })) + openGates.set(acquisitionGeneration!, (model) => + resolve({ reportedOptions: { model, reportedPermissionMode: 'ask' } }) + ) }), acquire, dispatch, @@ -270,6 +272,59 @@ describe('a send while the agent starts', () => { }) }) +it.each([false, true])( + 'keeps current-generation reports across readiness (replaced=%s)', + async (replaced) => { + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + permissionMode: 'own', + now: NOW + }) + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + const fence = store.getRecord(SESSION)!.lease.runtimeFence + const acquisitionGeneration = `generation-${acquire.mock.calls.length}` + const report = ( + reportedOptions: { reportedPermissionMode?: string | null }, + generation = acquisitionGeneration, + reportFence = fence + ) => + host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: reportFence, + acquisitionGeneration: generation, + reportedOptions + }) + const early = report({ reportedPermissionMode: 'accept-edits' }) + expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull() + if (replaced) { + await host.close(SESSION, 'evict') + await early + await proveOpen() + await host.flushStreamedEvents(SESSION) + expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull() + return + } + await proveOpen() + await early + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + const current = () => host.readStatusSummary(SESSION)?.runtimePermission + expect(current()).toEqual({ fence, mode: 'accept-edits' }) + await report({ reportedPermissionMode: 'full-access' }, 'foreign') + await report({ reportedPermissionMode: 'full-access' }, acquisitionGeneration, fence + 1) + await report({}) + expect(current()).toEqual({ fence, mode: 'accept-edits' }) + await report({ reportedPermissionMode: null }) + expect(current()).toEqual({ fence, mode: null }) + expect(store.getRecord(SESSION)?.permissionMode).toBe('own') + expect(dispatch).toHaveBeenCalledTimes(1) + await host.close(SESSION, 'evict') + expect(current()).toBeNull() + } +) + describe('a start that ends before it proves itself', () => { it.each([false, true])( 'settles only its own message after startup failure (acquisition refusal: %s)', 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 ff938b33886..8e1f2503166 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 @@ -8,7 +8,7 @@ export type { StructuredAgentSessionStartReadiness } from './structured-agent-se 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 { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { sameProviderChild } from './structured-agent-session-provider-child' import { @@ -21,7 +21,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { record: AgentSessionRecord identity: AgentSessionJournalIdentity spawnToken: string - permissionMode?: AgentChatPermissionMode + permissionMode?: AgentChatPermissionChoice events?: StructuredAgentSessionEventSink signal?: AbortSignal startupOptions?: Readonly> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts index f59fde3446f..e3d4e62e09c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts @@ -69,3 +69,28 @@ export function structuredAgentSessionNamingSnapshot( namingActiveSessionIds: deps.namingActiveSessionIds?.() ?? [] } } + +/** Activity observers remain separate from descriptive provider reports. */ +export function notifyStructuredStatusObservers( + deps: StructuredAgentSessionStatusFeedDeps, + summary: AgentSessionStatusSummary, + previous: AgentSessionStatusSummary | undefined, + options: StructuredAgentSessionStatusObserverOptions & { reportOnly: boolean } +): void { + if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { + deps.onAgentStarted?.(summary.sessionId) + } + const { reportOnly, ...observation } = options + if (reportOnly) { + return + } + try { + deps.onStatusChanged?.(summary, observation) + } catch (error) { + deps.logger.warn('status observer failed', { + scope: 'status-observer', + sessionId: summary.sessionId, + error + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 006f01ad780..24c021b8906 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -155,6 +155,7 @@ describe('StructuredAgentSessionStatusFeed', () => { agent: 'codex', status: null, latestPrompt: '', + runtimePermission: null, updatedAt: expect.any(Number) } ] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 5f92331c8e7..8bd2549c079 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -11,6 +11,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { structuredAgentSessionNamingSnapshot, + notifyStructuredStatusObservers, type StatusFeedSession, type StatusPublication, type StructuredAgentSessionStatusSubscriber, @@ -25,7 +26,10 @@ import { StructuredAgentSessionJournalProjections, type StructuredAgentSessionJournalProjection } from './structured-agent-session-status-journal-projection' -import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' +import { + permissionReportOnly, + structuredStatusSummariesEqual +} from './structured-agent-session-status-summary-equality' import type { StructuredAgentSessionChildWorkProducer } from './structured-agent-session-subagent-rows' import { StructuredAgentSessionStatusOwnership, @@ -127,6 +131,7 @@ export class StructuredAgentSessionStatusFeed { restartResume: _restartResume, ...retained } = previous + retained.runtimePermission = null this.published.set(sessionId, { summary: retained, firstInputSubmissionKey: publication?.firstInputSubmissionKey ?? null @@ -192,6 +197,7 @@ export class StructuredAgentSessionStatusFeed { const previous = publication?.summary const summaryChanged = !previous || !structuredStatusSummariesEqual(previous, summary) const inputChanged = publication?.firstInputSubmissionKey !== projection.firstInputSubmissionKey + const reportOnly = !inputChanged && permissionReportOnly(previous, summary) if (!summaryChanged && !inputChanged) { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { this.sink(summary, session.params.location) @@ -203,20 +209,16 @@ export class StructuredAgentSessionStatusFeed { firstInputSubmissionKey: projection.firstInputSubmissionKey }) if (summaryChanged) { - this.sink(summary, session.params.location) + if (!reportOnly || !this.ownership.matchesLocation(sessionId, session.params.location)) { + this.sink(summary, session.params.location) + } this.broadcast({ type: 'status', session: summary }) } - if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { - this.deps.onAgentStarted?.(sessionId) - } - try { - this.deps.onStatusChanged?.(summary, { - replay: options?.replay === true, - firstInputSubmissionKey: projection.firstInputSubmissionKey - }) - } catch (error) { - this.logFailure('status-observer', 'status observer failed', sessionId, error) - } + notifyStructuredStatusObservers(this.deps, summary, previous, { + reportOnly, + replay: options?.replay === true, + firstInputSubmissionKey: projection.firstInputSubmissionKey + }) } /** Retain settled children until the next accepted send to reject late revival and allow final outcomes. @@ -226,10 +228,9 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, acceptedSendKey: string ): void { - const seen = this.acceptedSends.has(sessionId) const previous = this.acceptedSends.get(sessionId) this.acceptedSends.set(sessionId, acceptedSendKey) - if (!seen || acceptedSendKey === previous) { + if (previous === undefined || acceptedSendKey === previous) { return } this.admitChildWork(sessionId, session, [{ type: 'turn-started', observedAt: this.deps.now() }]) 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 3271c85258b..1a594d95eba 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 @@ -27,6 +27,9 @@ export function structuredStatusSummariesEqual( a.latestPrompt === b.latestPrompt && a.model === b.model && a.permissionMode === b.permissionMode && + a.runtimePermission?.fence === b.runtimePermission?.fence && + a.runtimePermission?.mode === b.runtimePermission?.mode && + (a.runtimePermission === undefined) === (b.runtimePermission === undefined) && a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && @@ -55,3 +58,16 @@ function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean ) ) } + +export function permissionReportOnly( + previous: AgentSessionStatusSummary | undefined, + summary: AgentSessionStatusSummary +): boolean { + return Boolean( + previous && + structuredStatusSummariesEqual( + { ...previous, runtimePermission: summary.runtimePermission }, + summary + ) + ) +} 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 2d3dd29d1aa..d5b632dcd55 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 @@ -1,3 +1,4 @@ +import { structuredAgentSessionRuntimePermission } from './structured-agent-session-ran-options' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' @@ -29,7 +30,7 @@ export function structuredAgentSessionStatusSummary({ sessionId: string session: { params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } - child?: Pick | null + child?: StructuredAgentSessionProviderChild | null restartResume?: AgentSessionStatusSummary['restartResume'] } journal: AgentSessionJournal @@ -59,6 +60,8 @@ export function structuredAgentSessionStatusSummary({ : {}), ...(session.restartResume ? { restartResume: { phase: session.restartResume.phase } } : {}), ...projected, + runtimePermission: structuredAgentSessionRuntimePermission(session.child ?? undefined), + // Only a working session is still being stopped; any other status already ended that work. ...(stopping && projected.status === 'working' ? { stopping: true as const } : {}), ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } diff --git a/src/main/persistence/applying-settings/legacy-chat-permission-write.ts b/src/main/persistence/applying-settings/legacy-chat-permission-write.ts new file mode 100644 index 00000000000..f7d874d859f --- /dev/null +++ b/src/main/persistence/applying-settings/legacy-chat-permission-write.ts @@ -0,0 +1,30 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { + isAgentChatPermissionMode, + publishedLegacyChatPermissionMode +} from '../../../shared/agent-chat-permission-mode' +import { applyAllAgentsPermission } from '../../../shared/tui-agent-permission-choice' + +/** Old dropdown writes use the replacement control's reset; an explicit typed write wins. */ +export function legacyChatPermissionWrite( + settings: GlobalSettings, + updates: Partial +): Partial { + const choice = + 'nativeChatPermissionMode' in updates && !('agentPermissionDefault' in updates) + ? applyAllAgentsPermission( + settings, + isAgentChatPermissionMode(updates.nativeChatPermissionMode) + ? updates.nativeChatPermissionMode + : 'ask' + ) + : {} + return { + ...choice, + nativeChatPermissionMode: publishedLegacyChatPermissionMode({ + ...settings, + ...updates, + ...choice + }) + } +} diff --git a/src/main/persistence/applying-settings/settings-update-normalization.test.ts b/src/main/persistence/applying-settings/settings-update-normalization.test.ts index c440416ad70..bf4e2dc88b1 100644 --- a/src/main/persistence/applying-settings/settings-update-normalization.test.ts +++ b/src/main/persistence/applying-settings/settings-update-normalization.test.ts @@ -122,25 +122,3 @@ describe('updateSettings terminalMinimumContrastRatio', () => { ) }) }) - -describe('updateSettings scoped chat defaults', () => { - it('clears agent overrides for a shared chat permission choice from any writer', () => { - const operations = settingsOperations({ - nativeChatPermissionMode: 'full-access', - agentNativeChatPermissionModes: { codex: 'auto' } - }) - expect( - updateSettings(operations, { nativeChatPermissionMode: 'ask' }).agentNativeChatPermissionModes - ).toEqual({}) - }) - - it('retains explicit overrides from a bulk mutation or durable rollback', () => { - const operations = settingsOperations({ nativeChatPermissionMode: 'ask' }) - expect( - updateSettings(operations, { - nativeChatPermissionMode: 'full-access', - agentNativeChatPermissionModes: { codex: 'auto' } - }).agentNativeChatPermissionModes - ).toEqual({ codex: 'auto' }) - }) -}) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 6b0e07d7864..a7b3427711a 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -1,4 +1,4 @@ -import { normalizeAgentNativeChatPermissionModes } from '../../../shared/agent-chat-permission-mode' +import { legacyChatPermissionWrite } from './legacy-chat-permission-write' import { normalizeNativeChatAppearanceSettings } from '../../../shared/native-chat-appearance-settings' import type { GlobalSettings } from '../../../shared/global-settings-types' import { retireAtRestCommandSettings } from '../../native-chat/agent-session-wire/at-rest-command-context' @@ -81,11 +81,6 @@ export function updateSettings( nestedWorkerMaxDepth: updates.nestedWorkerMaxDepth }) } - if ('nativeChatPermissionMode' in updates || 'agentNativeChatPermissionModes' in updates) { - sanitizedUpdates.agentNativeChatPermissionModes = normalizeAgentNativeChatPermissionModes( - updates.agentNativeChatPermissionModes - ) - } if ('nativeChatAppearance' in updates) { sanitizedUpdates.nativeChatAppearance = normalizeNativeChatAppearanceSettings( updates.nativeChatAppearance @@ -108,6 +103,12 @@ export function updateSettings( } } sanitizeAgentLaunchProfileUpdates(sanitizedUpdates, updates) + if ('nativeChatPermissionMode' in updates || 'agentPermissionDefault' in updates) { + Object.assign( + sanitizedUpdates, + legacyChatPermissionWrite(operations.state.settings, sanitizedUpdates) + ) + } if ('terminalQuickCommands' in updates) { const terminalQuickCommands = normalizeTerminalQuickCommands(updates.terminalQuickCommands) assertTerminalQuickCommandsStorable(terminalQuickCommands) diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 0e27487ae61..48286f2c6af 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -57,6 +57,7 @@ export function readLegacyTerminalScrollbackSettings( } type RetiredGlobalSettings = { + agentNativeChatPermissionModes?: unknown setupScriptLaunchMode?: unknown terminalScrollbackBytes?: unknown enableGitHubAttribution?: unknown @@ -73,6 +74,7 @@ export function stripRetiredGlobalSettings( settings: Partial | undefined ): Partial { const { + agentNativeChatPermissionModes: _retiredChatPermissionCopies, setupScriptLaunchMode: _legacySetupScriptLaunchMode, terminalScrollbackBytes: _legacyScrollbackBytes, enableGitHubAttribution: _legacyGitHubAttribution, @@ -83,6 +85,7 @@ export function stripRetiredGlobalSettings( openAgentTabsInChatByDefault: _legacyChatDefaultView, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings + void _retiredChatPermissionCopies void _legacySetupScriptLaunchMode void _legacyScrollbackBytes void _legacyGitHubAttribution 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 f4d16332f26..402eb9f4a63 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 @@ -1,3 +1,4 @@ +import { newChatPermissionModeForSettings } from '../../../shared/agent-chat-permission-mode' import { homedir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../../shared/constants' @@ -6,7 +7,6 @@ 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' import { resolveAgentPermission } from '../../../shared/tui-agent-permission-choice' import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults' @@ -164,7 +164,6 @@ describe('the new-chat permission setting', () => { saved: Partial raw: string[] choices: string[] - chat: string terminal: string[] }>([ { @@ -178,7 +177,6 @@ describe('the new-chat permission setting', () => { }, raw: ['--model sonnet', '--config tui.animations=false'], choices: ['full-access', 'auto'], - chat: 'ask', terminal: [ '--dangerously-skip-permissions --model sonnet', '--approve-for-me --config tui.animations=false' @@ -193,7 +191,6 @@ describe('the new-chat permission setting', () => { }, raw: ['', ''], choices: ['own', 'own'], - chat: 'auto', terminal: ['', ''] }, { @@ -205,7 +202,6 @@ describe('the new-chat permission setting', () => { }, raw: ['--permission-mode plan --model sonnet', ''], choices: ['own', 'own'], - chat: 'accept-edits', terminal: ['--permission-mode plan --model sonnet', ''] }, { @@ -213,13 +209,15 @@ describe('the new-chat permission setting', () => { saved: { agentYoloDefaultsMigrated: false, agentDefaultArgs: {} }, raw: ['', ''], choices: ['full-access', 'full-access'], - chat: 'full-access', terminal: ['--dangerously-skip-permissions', '--dangerously-bypass-approvals-and-sandbox'] } ])( 'loads and reloads $name through the real profile pipeline', - ({ saved, raw, choices, chat, terminal }) => { + ({ saved, raw, choices, terminal }) => { const defaults = getDefaultPersistedState(homedir()) + const retiredChatSettings = { + agentNativeChatPermissionModes: { claude: 'ask', codex: 'auto' } + } const parsed: PersistedState = { ...defaults, settings: { @@ -227,7 +225,7 @@ describe('the new-chat permission setting', () => { agentPermissionDefault: undefined, agentPermissionOverrides: undefined, nativeChatPermissionMode: undefined, - agentNativeChatPermissionModes: { claude: 'ask', codex: 'auto' }, + ...retiredChatSettings, agentDefaultEnv: { claude: { QA_LOADER_NOTE: 'preserved' } }, ...saved } @@ -242,8 +240,12 @@ describe('the new-chat permission setting', () => { expect(needsSave).toHaveBeenCalled() expect(loaded.agentYoloDefaultsMigrated).toBe(true) expect(loaded.agentPermissionDefault).toBeDefined() - expect(loaded.nativeChatPermissionMode).toBe(chat) - expect(loaded.agentNativeChatPermissionModes).toEqual({ claude: 'ask', codex: 'auto' }) + expect( + (['claude', 'codex'] as const).map((agent) => + newChatPermissionModeForSettings(agent, loaded) + ) + ).toEqual(choices) + expect(loaded).not.toHaveProperty('agentNativeChatPermissionModes') expect(loaded.agentDefaultEnv?.claude).toEqual({ QA_LOADER_NOTE: 'preserved' }) expect( (['claude', 'codex'] as const).map((agent) => loaded.agentDefaultArgs?.[agent]) @@ -264,38 +266,42 @@ describe('the new-chat permission setting', () => { ).toEqual(loaded) } ) - - 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) - }) - - it('keeps a saved value when Agent Permissions change afterwards', () => { - const normalized = normalizeLegacyProfile({ - nativeChatPermissionMode: 'ask', - ...applyAgentPermissionMode({ mode: 'yolo' }) - }) - expect(normalized.nativeChatPermissionMode).toBe('ask') - }) }) describe('scoped new-chat permission defaults', () => { - it('preserves an agent override across load while rejecting unsupported stored modes', () => { + it('retires old chat copies while terminal intent and existing chat choices survive', () => { const normalized = normalizeLegacyProfile({ nativeChatPermissionMode: 'full-access', agentNativeChatPermissionModes: { codex: 'auto', claude: 'ask', omp: 'auto', unknown: 'auto' } }) - expect(normalized.agentNativeChatPermissionModes).toEqual({ codex: 'auto', claude: 'ask' }) + expect(normalized).not.toHaveProperty('agentNativeChatPermissionModes') expect(normalized.nativeChatPermissionMode).toBe('full-access') }) }) +it('migrates a shipped split chat/terminal profile using terminal choices while retaining other saved data', () => { + const normalized = normalizeLegacyProfile({ + agentPermissionDefault: undefined, + agentPermissionOverrides: undefined, + agentPermissionMigrated: false, + agentDefaultArgs: { claude: '--model opus --permission-mode acceptEdits', codex: '' }, + nativeChatPermissionMode: 'ask', + agentNativeChatPermissionModes: { claude: 'full-access', codex: 'auto' }, + nativeChatSessionOptions: { + claude: { model: 'opus', valuesByModel: { opus: { effort: 'high' } } } + } + }) + expect(normalized).not.toHaveProperty('agentNativeChatPermissionModes') + expect(newChatPermissionModeForSettings('claude', normalized)).toBe('accept-edits') + expect(newChatPermissionModeForSettings('codex', normalized)).toBe('own') + expect(normalized.nativeChatPermissionMode).toBe('ask') + expect(normalized.agentDefaultArgs?.claude).toBe('--model opus') + expect(normalized.nativeChatSessionOptions?.claude).toEqual({ + model: 'opus', + valuesByModel: { opus: { effort: 'high' } } + }) +}) + describe('terminal default shell', () => { it('trims a hand-edited value and treats whitespace-only as unset', () => { expect( 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 346f0f2bc67..aa3a5ce27ff 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -9,10 +9,7 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell' import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat-shell-environment' -import { - loadedChatPermissionSetting, - normalizeAgentNativeChatPermissionModes -} from '../../../shared/agent-chat-permission-mode' +import { publishedLegacyChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import { savedChatUiWithTerminalDefaultView, stripRetiredGlobalSettings @@ -134,17 +131,12 @@ export function normalizeLoadedGlobalSettings( nativeChatShellEnvironmentVariables: normalizeNativeChatShellEnvironmentVariables( parsed.settings?.nativeChatShellEnvironmentVariables ), - agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes( - parsed.settings?.agentNativeChatPermissionModes - ), - nativeChatPermissionMode: loadedChatPermissionSetting( - parsed.settings?.nativeChatPermissionMode, - { - ...migratedAgentYoloDefaults, - terminalWindowsShell: parsed.settings?.terminalWindowsShell - }, - process.platform - ), + // Old clients identify hosts with per-chat permissions through this valid value. + nativeChatPermissionMode: publishedLegacyChatPermissionMode({ + ...parsed.settings, + ...migratedAgentYoloDefaults, + ...migratedAgentPermission + }), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, visibleTaskProvidersDefaultedForJira: true, diff --git a/src/main/runtime/agent-launch-settings-rollback.ts b/src/main/runtime/agent-launch-settings-rollback.ts index d1bc3926780..185e83fb899 100644 --- a/src/main/runtime/agent-launch-settings-rollback.ts +++ b/src/main/runtime/agent-launch-settings-rollback.ts @@ -43,10 +43,6 @@ export function agentLaunchSettingsRollbackUpdates( Object.is(current.agentPermissionOverrides, applied.agentPermissionOverrides) ? { agentPermissionOverrides: before.agentPermissionOverrides ?? {} } : {}), - ...(Object.hasOwn(updates, 'agentNativeChatPermissionModes') && - Object.is(current.agentNativeChatPermissionModes, applied.agentNativeChatPermissionModes) - ? { agentNativeChatPermissionModes: before.agentNativeChatPermissionModes ?? {} } - : {}), ...(Object.hasOwn(updates, 'nativeChatPermissionMode') && Object.is(current.nativeChatPermissionMode, applied.nativeChatPermissionMode) ? { nativeChatPermissionMode: before.nativeChatPermissionMode ?? 'ask' } diff --git a/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts b/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts index 33611437c72..d4ef3c3a54e 100644 --- a/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts +++ b/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts @@ -31,6 +31,43 @@ vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ })) describe('durable execution-host launch settings', () => { + it('maps an old dropdown through the all-agents reset, persists it and publishes typed precedence', async () => { + const { store, readState } = await fixture() + const controller = new RuntimeClientSettingsController(store) + await controller.mutateAgentLaunch({ + type: 'permission', + agent: 'claude', + choice: 'full-access' + }) + await controller.update({ nativeChatPermissionMode: 'ask' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings).toMatchObject({ + agentPermissionDefault: 'ask', + agentPermissionOverrides: {} + }) + expect(controller.get().nativeChatPermissionMode).toBe('ask') + expect(resolveTuiAgentLaunchArgs('claude', store.getSettings())).toBe( + '--permission-mode default' + ) + expect(newChatPermissionModeForSettings('claude', store.getSettings())).toBe('ask') + store.updateSettings({ nativeChatPermissionMode: 'full-access', agentPermissionDefault: 'own' }) + await store.flushPendingOrThrowAsync() + const saved = readState() + expect(saved.settings.agentPermissionDefault).toBe('own') + const reloaded = new Store({ + serializedState: JSON.stringify(saved), + dataFile: join(saved.settings.workspaceDir, 'legacy-permission-reload.json') + }) + try { + expect(new RuntimeClientSettingsController(reloaded).get().nativeChatPermissionMode).toBe( + 'ask' + ) + expect(newChatPermissionModeForSettings('claude', reloaded.getSettings())).toBe('own') + } finally { + await reloaded.freezeWritesAsync() + } + }) + it('persists host carrier edits and reloads the same terminal and new-chat intent', async () => { const { store, readState } = await fixture() const controller = new RuntimeClientSettingsController(store) @@ -344,11 +381,8 @@ describe('durable execution-host launch settings', () => { expect(store.getSettings().nativeChatPermissionMode).toBe(before) expect(readState().settings.nativeChatPermissionMode).toBe(before) await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'auto' }) - expect(readState().settings.nativeChatPermissionMode).toBe(before) - expect(readState().settings.agentNativeChatPermissionModes).toEqual({ - claude: 'auto', - codex: 'auto' - }) + expect(readState().settings.nativeChatPermissionMode).toBe('auto') + expect(readState().settings).not.toHaveProperty('agentNativeChatPermissionModes') }) it('rolls back a scoped chat default after a failed write and persists a retry', async () => { @@ -370,14 +404,14 @@ describe('durable execution-host launch settings', () => { await store.flushPendingOrThrowAsync().catch(() => {}) } expect(store.getSettings().agentDefaultArgs?.codex).toBe(beforeArgs) - expect(store.getSettings().agentNativeChatPermissionModes).toEqual({}) + expect(store.getSettings()).not.toHaveProperty('agentNativeChatPermissionModes') await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }) - expect(readState().settings.agentNativeChatPermissionModes).toEqual({ codex: 'auto' }) + expect(readState().settings).not.toHaveProperty('agentNativeChatPermissionModes') expect(readState().settings.nativeChatPermissionMode).toBe(beforeNative) expect(readState().settings.agentPermissionOverrides).toMatchObject({ codex: 'auto' }) }) diff --git a/src/main/runtime/runtime-client-agent-launch-settings.test.ts b/src/main/runtime/runtime-client-agent-launch-settings.test.ts index a9d106518a8..ad33e293367 100644 --- a/src/main/runtime/runtime-client-agent-launch-settings.test.ts +++ b/src/main/runtime/runtime-client-agent-launch-settings.test.ts @@ -122,7 +122,7 @@ describe('execution-host agent launch settings', () => { mode: 'manual' }) expect(resolveTuiAgentLaunchArgs('claude', host)).toBe('') - expect(host.nativeChatPermissionMode).toBe('ask') + expect(host.nativeChatPermissionMode).toBe('full-access') expect(host.agentDefaultEnv?.claude).toEqual({ API_KEY: 'host-secret', KEEP: 'host-only' }) expect(JSON.stringify(controller.getAgentLaunch())).not.toContain('host-only') }) @@ -137,7 +137,7 @@ describe('execution-host agent launch settings', () => { codex: '--approve-for-me' }) expect(host.nativeChatPermissionMode).toBe(beforeNative) - expect(host.agentNativeChatPermissionModes).toEqual({ claude: 'auto', codex: 'auto' }) + expect(host).not.toHaveProperty('agentNativeChatPermissionModes') }) it('publishes scoped host defaults and resets them when a paired client chooses a shared chat default', async () => { @@ -149,10 +149,10 @@ describe('execution-host agent launch settings', () => { agent: 'codex', mode: 'auto' }) - expect(controller.get().agentNativeChatPermissionModes).toEqual({ codex: 'auto' }) + expect(controller.get()).not.toHaveProperty('agentNativeChatPermissionModes') expect(host.agentDefaultArgs.claude).toBe('') await controller.update({ nativeChatPermissionMode: 'ask' }) - expect(controller.get().agentNativeChatPermissionModes).toEqual({}) + expect(controller.get()).not.toHaveProperty('agentNativeChatPermissionModes') expect(host.nativeChatPermissionMode).toBe('ask') }) diff --git a/src/main/runtime/runtime-client-settings-shape.ts b/src/main/runtime/runtime-client-settings-shape.ts index d49e9d172be..951452fed04 100644 --- a/src/main/runtime/runtime-client-settings-shape.ts +++ b/src/main/runtime/runtime-client-settings-shape.ts @@ -33,7 +33,6 @@ export type RuntimeClientSettings = Pick< | 'agentSkillSharingEnabled' | 'machineName' | 'nativeChatPermissionMode' - | 'agentNativeChatPermissionModes' > & { /** Older clients still read these; neither is persisted or accepted as an update. */ openAgentTabsInChatByDefault?: boolean diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 9886ab2472f..a6649b66b55 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -1,3 +1,4 @@ +import { publishedLegacyChatPermissionMode } from '../../shared/agent-chat-permission-mode' import { getAppEnvironment } from '../../shared/app-environment' import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' import { isAgentSkillSharingEnabled } from '../../shared/agent-skill-sharing-gate' @@ -88,14 +89,9 @@ export class RuntimeClientSettingsController { worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings), machineName: settings.machineName ?? '', - // Paired web Chat settings edit this host's new-chat default. - ...(settings.nativeChatPermissionMode - ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } - : {}), - ...(settings.agentNativeChatPermissionModes - ? { agentNativeChatPermissionModes: settings.agentNativeChatPermissionModes } - : {}), - // Project per-action agent recipes so paired AI buttons honor host choices; older hosts omit them. + nativeChatPermissionMode: publishedLegacyChatPermissionMode(settings), + // 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), hostSettingOverrides: Object.fromEntries( [ @@ -126,9 +122,6 @@ export class RuntimeClientSettingsController { process.platform ) : {}), - ...(updates.nativeChatPermissionMode !== undefined - ? { agentNativeChatPermissionModes: {} } - : {}), ...(updates.defaultTuiAgent !== undefined ? { newWorkspaceSetupOnly: false } : {}) }, { notifyListeners: true } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 66dd78c39e6..b27e5e09338 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -175,7 +175,6 @@ export type RuntimeStore = { agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions'] nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode'] - agentNativeChatPermissionModes?: GlobalSettings['agentNativeChatPermissionModes'] 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 647e3ad353d..4791bc12acd 100644 --- a/src/main/runtime/structured-agent-launch-composition.test-support.ts +++ b/src/main/runtime/structured-agent-launch-composition.test-support.ts @@ -1,7 +1,7 @@ // Build agent launch resolvers with production composition over test doubles so tests exercise the shared path. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' import type { AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' import { @@ -81,7 +81,7 @@ function withTestSpawnToken( return (input: { identity: AgentSessionJournalIdentity spawnToken?: string | null - permissionMode?: AgentChatPermissionMode + permissionMode?: AgentChatPermissionChoice }) => { const spawnToken = input.spawnToken === undefined ? defaultToken : input.spawnToken return spawnToken === null diff --git a/src/main/runtime/structured-agent-launch-composition.ts b/src/main/runtime/structured-agent-launch-composition.ts index 1feec13a36a..89caff5e610 100644 --- a/src/main/runtime/structured-agent-launch-composition.ts +++ b/src/main/runtime/structured-agent-launch-composition.ts @@ -1,7 +1,7 @@ // Compose shared admission, cwd, environment, visuals and child sealing; agent parts add only agent-specific inputs and never set cwd/sealed env. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import type { AgentChatPermissionChoice } 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' @@ -49,14 +49,14 @@ export type StructuredAgentLaunchBasis = { /** After a missing program, refresh both environment reads together; absent means no retry. */ refreshAfterMissingProgram?: (askedAt: number) => Promise /** The permission mode the host decided this start runs with; absent asks. */ - permissionMode?: AgentChatPermissionMode + permissionMode?: AgentChatPermissionChoice } export type StructuredLaunchRequest = { identity: AgentSessionJournalIdentity /** The reservation's token, which the seal hands the child. */ spawnToken: string - permissionMode?: AgentChatPermissionMode + permissionMode?: AgentChatPermissionChoice } /** What the composition sets on a launch; an agent's part never does. */ @@ -135,7 +135,7 @@ async function composedPart( sources: StructuredAgentLaunchSources, part: StructuredAgentLaunchPartResolver, identity: AgentSessionJournalIdentity, - permissionMode?: AgentChatPermissionMode + permissionMode?: AgentChatPermissionChoice ): Promise & { cwd: string }> { const record = admittedRecord(definition, sources.store, identity.sessionId) const launchEnv = once(() => sources.resolveAgentLaunchEnv?.(definition.agent) ?? {}) diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx index 97ffa01e530..c79a7e5380d 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx @@ -2,7 +2,7 @@ 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' +import type { AgentChatPermissionChoice } from '../../../../shared/agent-chat-permission-mode' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => @@ -82,6 +82,8 @@ vi.mock('@/components/ui/dropdown-menu', async () => { } }) +import { structuredAgentSessionPermissionDescriptor } from '../../../../shared/structured-agent-session-permission-option' +import { createStructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' import { NativeChatComposerPermissionPicker, @@ -101,7 +103,7 @@ function props( } } -function option(mode: AgentChatPermissionMode): HTMLElement { +function option(mode: AgentChatPermissionChoice): HTMLElement { return screen.getByRole('radio', { name: (_name, element) => element.getAttribute('data-value') === mode }) @@ -204,6 +206,65 @@ describe('NativeChatComposerPermissionPicker', () => { subscribe: () => () => {} }) + it.each([ + ['claude', 'accept-edits', 'Accept edits'], + ['codex', 'full-access', 'Full access'], + ['claude', 'plan', 'Plan mode'], + ['codex', 'read-only', 'Read only'] + ])( + 'checks only an offered reported mode for %s/%s and clears on report loss', + (agent, mode, label) => { + const options = surface() + const state = createStructuredAgentSessionOptionState(agent, null) + const snapshot = (reportedPermissionMode?: string | null) => { + const row = structuredAgentSessionPermissionDescriptor({ + ...state, + permissionMode: 'own', + permissionOwnSupported: true, + reportedPermissionMode + }) + return row ? [row] : [] + } + const { rerender } = render( + + ) + expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true') + for (const report of [mode, null, mode, undefined]) { + rerender( + + ) + expect( + screen.getByRole('button', { name: `Permissions ${report ? label : 'Permissions'}` }) + ).toBeTruthy() + expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe( + report ? null : 'true' + ) + expect( + screen + .getAllByRole('radio') + .filter((row) => row.getAttribute('aria-checked') === 'true') + .map((row) => row.getAttribute('data-value')) + ).toEqual(report && ['accept-edits', 'full-access'].includes(report) ? [report] : []) + expect( + screen.getAllByRole('radio').some((row) => row.getAttribute('data-value') === 'own') + ).toBe(false) + } + rerender( + + ) + fireEvent.click(option('full-access')) + expect(options.setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access') + } + ) + it('renders nothing without a permission descriptor', () => { const { container } = render( @@ -211,7 +272,7 @@ describe('NativeChatComposerPermissionPicker', () => { expect(container.innerHTML).toBe('') }) - it('writes a new pick through the option surface, and skips the current one', () => { + it('writes explicit picks through the option surface even when the report matches', () => { const options = surface() render( { /> ) fireEvent.click(option('ask')) - expect(options.setOption).not.toHaveBeenCalled() + expect(options.setOption).toHaveBeenCalledWith('permissionMode', 'ask') // A write that waits behind a starting agent leaves the pill usable. options.setOption.mockImplementation(() => new Promise(() => {})) fireEvent.click(option('full-access')) diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx index bda5e2e1427..6622eb88af3 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { ChevronDown } from 'lucide-react' +import { ChevronDown, Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -77,11 +77,12 @@ export function NativeChatPermissionModeItems({ } export type NativeChatPermissionModePickerProps = { - current: AgentChatPermissionMode + current: string | undefined modes: readonly AgentChatPermissionMode[] /** Whose descriptions to show; absent describes every agent's. */ agent?: string | null disabled?: boolean + currentLabel?: string onSelect: (mode: AgentChatPermissionMode) => void } @@ -90,9 +91,13 @@ function NativeChatPermissionModePickerInner({ modes, agent, disabled = false, - onSelect + onSelect, + currentLabel }: NativeChatPermissionModePickerProps): React.JSX.Element { const title = nativeChatPermissionPickerTitle() + const label = isAgentChatPermissionMode(current) + ? nativeChatPermissionModeLabel(current) + : (currentLabel ?? title) // `shrink` overrides the button's shrink-0 so a narrow composer truncates the mode name. return ( @@ -106,11 +111,18 @@ function NativeChatPermissionModePickerInner({ aria-label={translate( 'components.native-chat.composer.pillAccessibleName', '{{value0}} {{value1}}', - { value0: title, value1: nativeChatPermissionModeLabel(current) } + { value0: title, value1: label } )} className="max-w-40 min-w-0 shrink" > - + {isAgentChatPermissionMode(current) ? ( + + ) : ( + <> + + {label} + + )} @@ -149,30 +161,27 @@ export function NativeChatComposerPermissionPicker({ }): 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) - ) { + if (!surface || !descriptor || descriptor.kind.type !== 'select') { return null } const modes = descriptor.kind.choices .map((choice) => choice.value) .filter(isAgentChatPermissionMode) + if (modes.length === 0) { + return null + } 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) - ) - } + // The report can match a new explicit pick while the chat still follows agent settings. + runSessionOptionSurfaceCall(mode, NO_PENDING_MARK, () => + surface.setOption(AGENT_CHAT_PERMISSION_MODE_OPTION_ID, mode) + ) }} /> ) diff --git a/src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx b/src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx index 020edb91bb0..b5f2bba199d 100644 --- a/src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ call: vi.fn(), confirm: vi.fn() })) let state: StructuredAgentSessionState vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call })) vi.mock('./use-structured-agent-session-hold', () => ({ diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx index e53d991c477..67a6fdd9875 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call })) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index cfce19f6e6f..627614dde36 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -23,6 +23,7 @@ let publishedTabs: unknown[] = [] vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx index cc4c6d21ff9..85e0d984b68 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx @@ -11,6 +11,7 @@ let items: AgentJournalRenderItem[] = [] let backgroundTasks: AgentSessionBackgroundTaskState | undefined vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx index a84c4b7cbcf..c32582de4cf 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx @@ -19,6 +19,7 @@ let items: AgentJournalRenderItem[] = [] vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: mocks.quietRepeatedStop })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx index 2485e5dbcb1..07394d14421 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx @@ -8,6 +8,7 @@ let items: AgentJournalRenderItem[] = [] let unloadedTurnRevisions: number | undefined vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, readStructuredAgentSessionConversationOutline: vi.fn(async () => null) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-legacy-recovery.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-legacy-recovery.test.tsx index 604302c8611..ffeaaa43179 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-legacy-recovery.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-legacy-recovery.test.tsx @@ -16,6 +16,7 @@ let items: AgentJournalRenderItem[] = [] let submissions: AgentJournalSubmission[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, readStructuredAgentSessionConversationOutline: mocks.outline, supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false), 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 05e17f34935..6c0b4b81f60 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 @@ -1,3 +1,5 @@ +import { projectRuntimePermission } from '../../../../shared/structured-agent-session-permission-option' +import { useStructuredAgentSessionStatusSummary } from './StructuredAgentSessionStatusBridge' import { useCallback, useEffect, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session/agent-session-conversation-command' import type { @@ -22,6 +24,7 @@ import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../sha import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import { useStructuredAgentSessionHostCapability, + useStructuredAgentSessionOwnPermissionState, structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' @@ -80,6 +83,7 @@ export function useStructuredAgentSessionOptionState(args: { turnId, worktree } = args + const { summary, observation } = useStructuredAgentSessionStatusSummary(sessionId, target) const [conversationSupport, setConversationSupport] = useState<{ sessionId: string commands: readonly AgentSessionConversationCommand[] @@ -120,7 +124,7 @@ export function useStructuredAgentSessionOptionState(args: { [] ) const hostKey = structuredAgentSessionHostKey(target) - const contextKey = JSON.stringify([identity, hostKey, newLaunch, worktree, fence]) + const contextKey = JSON.stringify([identity, hostKey, newLaunch, worktree, fence, observation]) const contextRef = useRef(contextKey) useEffect(() => { contextRef.current = contextKey @@ -145,7 +149,8 @@ export function useStructuredAgentSessionOptionState(args: { ...previous, catalogSource: 'host' as const, record: cloneStructuredAgentSessionOptionRecord(previous.record), - pendingId: null + pendingId: null, + reportedPermissionMode: undefined } : seeded const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept @@ -270,14 +275,23 @@ export function useStructuredAgentSessionOptionState(args: { } } } + const permissionOwnSupport = useStructuredAgentSessionOwnPermissionState(target) + const permissionOwnSupported = + permissionOwnSupport === 'unknown' ? null : permissionOwnSupport === 'supported' const takesSendOptions = useStructuredAgentSessionHostCapability( target, AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY ) return { - optionView: (...args: Parameters) => { - const view = structuredAgentSessionOptionView(...args) + // What the picker shows and a send carries. + optionView: ( + ...args: Parameters + ): StructuredAgentSessionOptionState => { + const view = { + ...projectRuntimePermission(structuredAgentSessionOptionView(...args), summary, fence), + permissionOwnSupported + } return pendingOptionsRef.current.model === undefined ? view : { ...view, pendingId: 'model' } }, takesSendOptions, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx index 2bce06ce5ed..2376a1f33f9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx @@ -13,7 +13,8 @@ vi.mock('@/runtime/structured-agent-session-host-capability', async (original) = })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })) })) vi.mock('./native-chat-session-option-settings-write', () => ({ enqueueSessionOptionSettingsWrite: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx index 5f32af73972..853b4c6adbd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call })) vi.mock('./native-chat-session-option-settings-write', () => ({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx index c8e7ab02da9..9599bc54749 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -1,3 +1,9 @@ +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY, + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY +} from '../../../../shared/agent-session-permission-modes-capability' +import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' // @vitest-environment happy-dom import { act, renderHook, waitFor } from '@testing-library/react' @@ -8,7 +14,8 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })) })) vi.mock('./native-chat-session-option-settings-write', () => ({ @@ -72,6 +79,7 @@ function renderReopened(fence: number, agent: 'codex' | 'claude' = 'codex') { describe('the permission mode another device picks', () => { beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY]) mocks.call.mockReset() resetHostModelCatalogSnapshotsForTests() }) @@ -99,6 +107,22 @@ describe('the permission mode another device picks', () => { unmount() }) + it('waits for the new child’s report after an own-settings chat restarts', async () => { + optionsAnswering('own') + const read = mocks.call.getMockImplementation()! + mocks.call.mockImplementation(async (...args) => { + const answer = await read(...args) + return { ...answer, current: { ...answer.current, reportedPermissionMode: 'full-access' } } + }) + const { result, rerender, unmount } = renderReopened(1) + const row = () => result.current.optionSnapshot.find((entry) => entry.id === 'permissionMode') + await waitFor(() => expect(row()).toMatchObject({ description: 'Full access', settable: true })) + mocks.call.mockImplementation(() => new Promise(() => {})) + rerender({ fence: 2, stamp: 'model-a|own' }) + expect(row()).toMatchObject({ description: 'Permissions', settable: false }) + unmount() + }) + it('shows no picker for a host that states no mode', async () => { optionsAnswering(undefined) const { result, unmount } = renderReopened(1) @@ -115,11 +139,51 @@ describe('the permission mode another device picks', () => { describe('a permission pick on this device', () => { beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY]) mocks.call.mockReset() vi.mocked(mutate).mockReset() resetHostModelCatalogSnapshotsForTests() }) + it('writes an explicit per-chat pick matching the report without changing agent settings', async () => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY + ]) + mocks.call.mockResolvedValue({ + models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }], + current: { model: 'model-a', permissionMode: 'own', reportedPermissionMode: 'full-access' } + }) + vi.mocked(mutate).mockResolvedValue({ options: { permissionMode: 'full-access' } }) + vi.mocked(enqueueSessionOptionSettingsWrite).mockClear() + const { result, unmount } = renderReopened(1) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('full-access')) + await act(async () => { + expect(await result.current.setStructuredOption('permissionMode', 'full-access')).toBe(true) + }) + expect(mutate).toHaveBeenCalledExactlyOnceWith( + 'agentSession.setOption', + 'agentSession.setOption', + { + key: 'permissionMode', + value: 'full-access' + } + ) + expect(enqueueSessionOptionSettingsWrite).not.toHaveBeenCalled() + unmount() + }) + + it('a PR2 client never sends own to a PR1 host', async () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY]) + optionsAnswering('ask') + const { result, unmount } = renderReopened(1) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) + expect(await result.current.setStructuredOption('permissionMode', 'own')).toBe(false) + expect(mutate).not.toHaveBeenCalled() + unmount() + setLocalRuntimeCapabilitiesForTests(null) + }) + it('shows at once and stays pickable while the write waits behind a start', async () => { optionsAnswering('ask', 'auto') const first = Promise.withResolvers<{ options: Record }>() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx index 9d67b229114..0c7a02e4644 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx @@ -11,10 +11,12 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call })) vi.mock('@/runtime/local-structured-chats', () => ({ localStructuredChatsInUse: async () => true })) vi.mock('@/runtime/local-runtime-capabilities', () => ({ + subscribeLocalRuntimeCapabilityObservation: () => () => {}, subscribeLocalRuntimeCapabilitiesKnown: () => () => {}, ensureLocalRuntimeCapabilities: async () => ['agent-session.model-catalog.saved-only.v1'], readLocalRuntimeCapabilitiesOrUnknown: () => ['agent-session.model-catalog.saved-only.v1'] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 32204a1505d..ea19fc16f5b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call })) 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 d7177408ea5..6452e68cb27 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 @@ -221,6 +221,9 @@ export function useStructuredAgentSessionOptions(args: { } const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( + (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID && + value === 'own' && + !view.permissionOwnSupported) || !isCurrentContext() || !optionCatalog || encoded === null || diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx index e905c805a96..6a3eae82210 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false), supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx index f711e6465a2..95138be7f9f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx @@ -35,6 +35,7 @@ const items: AgentJournalRenderItem[] = [ ] vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: vi.fn().mockResolvedValue(null) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index b99a6d8f0f1..2daa1acb08d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => { let readState: StructuredAgentSessionState vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx index d801fb85f6f..60142874b00 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: async () => true, supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx index 928e3751f0c..c85ef0fce0e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx @@ -23,6 +23,7 @@ let fence = 3 let latestTurn: AgentSessionLatestTurn | null | undefined vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false), supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index 1ca666ea436..56e15abe719 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.clear-send.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.clear-send.test.tsx index 2ea95913d52..8e050f07a57 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.clear-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.clear-send.test.tsx @@ -9,6 +9,7 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo const mocks = vi.hoisted(() => ({ call: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx index 18b6807d940..386af089a4b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -41,6 +41,7 @@ const items: AgentJournalRenderItem[] = [ vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: vi.fn(async () => null), supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx index 7510e9090ca..0af5841c40a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx @@ -14,6 +14,7 @@ let items: AgentJournalRenderItem[] = [] const submissions: AgentJournalSubmission[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index a3ad8e0673a..cf65844c1b6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -27,6 +27,7 @@ let nextQueuedMessageId: string | null = null let pendingSends: StructuredAgentSessionPendingSend[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx index 7f51c355995..005ca75f916 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx @@ -43,6 +43,7 @@ const KEPT_SUBMISSION: AgentJournalSubmission = { } vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: vi.fn(async () => null), supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx index 5b5a763c0cf..d1c51815bc7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx @@ -28,6 +28,7 @@ let submissions: AgentJournalSubmission[] = [] let queuedMessages: AgentSessionQueuedMessage[] | null = null vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 77bc254fd4a..08a6f047ccd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -17,6 +17,7 @@ let items: AgentJournalRenderItem[] = [] let submissions: AgentJournalSubmission[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) })) @@ -117,7 +118,6 @@ describe('useStructuredAgentSession working state', () => { beforeEach(() => { vi.clearAllMocks() fence = 3 - submissions = [] mocks.call.mockResolvedValue(null) }) diff --git a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx index 1bb2e01584e..68b578610ba 100644 --- a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx +++ b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx @@ -64,6 +64,7 @@ export function AgentLaunchSettingsCatalog({ refreshSettings, autoSupported = true, nativeChatPermissionsSupported = false, + chatOwnSupported = false, local }: { settings: AgentLaunchSettings @@ -74,6 +75,7 @@ export function AgentLaunchSettingsCatalog({ refreshSettings?: () => void autoSupported?: boolean nativeChatPermissionsSupported?: boolean + chatOwnSupported?: boolean local?: { settings: GlobalSettings saveEnvironment: (agent: TuiAgent, value: Record) => Promise @@ -127,6 +129,7 @@ export function AgentLaunchSettingsCatalog({ const chatSupport = agentChatPermissionModes(agent) return { choices, + chatOwnSupported, ...resolved, terminalChoice: agentPermissionArgs(agent, resolved.choice) || @@ -257,6 +260,7 @@ export function AgentLaunchSettingsCatalog({ /> {permissionSettings ? ( ) => void | Promise renderPermissions: AgentPermissionsRenderer }): React.JSX.Element { + const chatOwnSupported = useChatSettingsOwnPermissions() const tail = useRef | null>(null) const apply = ( agent: TuiAgent | undefined, @@ -121,6 +123,7 @@ function LocalAgentLaunchSettings({ hostName={null} renderPermissions={renderPermissions} nativeChatPermissionsSupported + chatOwnSupported={chatOwnSupported} local={{ settings, updateSettings, saveEnvironment }} /> ) diff --git a/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx b/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx index 1fe4ae9f78e..44209d9fe86 100644 --- a/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx +++ b/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx @@ -39,20 +39,27 @@ export function agentPermissionChoiceLabel( : translate('settings.agents.permissionOwnAll', "Agents' own settings") } -// Temporary copy until chats can run an agent's own settings: until then they keep Settings → Chat. -function ownDescription(): string { +function ownDescription(chatOwnSupported: boolean): string { + if (!chatOwnSupported) { + return translate( + 'settings.agents.permissionOwnLegacyDescription', + 'Orca adds no permission flag in terminals; chats use Settings → Chat.' + ) + } return translate( 'settings.agents.permissionOwnDescription', - 'Orca adds no permission flag in terminals; chats use Settings → Chat.' + 'Terminals and chats follow the agent’s own permission settings.' ) } export function AllAgentsPermissionSetting({ + chatOwnSupported = false, choice, withoutAutoChoice, onChange, onChangeWithoutAuto }: { + chatOwnSupported?: boolean choice: AgentPermissionChoice | null /** What the agents without Approve for me share; null when they differ. */ withoutAutoChoice: AgentPermissionChoice | null @@ -101,10 +108,7 @@ export function AllAgentsPermissionSetting({ {ownLabel}
- {translate( - 'settings.agents.permissionEachOwnDescription', - 'Orca adds nothing; each CLI uses its own config' - )} + {ownDescription(chatOwnSupported)}
@@ -158,6 +162,7 @@ export function AllAgentsPermissionSetting({ } export type AgentPermissionChoiceRow = { + chatOwnSupported?: boolean choices: readonly AgentPermissionChoice[] choice: AgentPermissionChoice source: 'agent' | 'all' @@ -227,7 +232,7 @@ export function AgentPermissionMenu({
{choice === 'own' - ? ownDescription() + ? ownDescription(permission.chatOwnSupported === true) : nativeChatPermissionModeDescription(choice, agentId)}
diff --git a/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx b/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx index fa9e07d1f1a..7c51caf606c 100644 --- a/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx +++ b/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx @@ -1,4 +1,4 @@ -import { AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-permission-modes-capability' import { getChatPermissionSearchEntry } from './chat-permission-search' // @vitest-environment happy-dom @@ -227,7 +227,8 @@ describe('ChatBehaviorSetting resume after restart', () => { }) describe('ChatBehaviorSetting permissions', () => { - it('shows the new-chat permission picker as the first Behavior row', () => { + it('keeps the legacy new-chat permission picker as the first Behavior row on older hosts', () => { + setLocalRuntimeCapabilitiesForTests([]) const { container } = renderSetting({ nativeChatPermissionMode: 'full-access' }) const rows = container.querySelectorAll('.divide-y > *') expect(rows[0]?.id).toBe('chat-permissions') @@ -241,16 +242,26 @@ describe('ChatBehaviorSetting permissions', () => { }) it.each([false, true])( - 'matches Chat copy and search to its API owner typed capability %s', - (typed) => { - setLocalRuntimeCapabilitiesForTests(typed ? [AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY] : []) + 'matches Chat copy and search to its API owner own-settings capability %s', + (ownSupported) => { + setLocalRuntimeCapabilitiesForTests( + ownSupported ? [AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY] : [] + ) const { container } = renderSetting({ nativeChatPermissionMode: 'ask', agentPermissionDefault: 'full-access', activeRuntimeEnvironmentId: 'unrelated-agents-host' }) - const description = getChatPermissionSearchEntry(typed).description + const description = getChatPermissionSearchEntry(ownSupported).description expect(container.querySelector('#chat-permissions')?.textContent).toContain(description) - expect(description?.includes('own settings')).toBe(typed) + expect(description?.includes('New chats follow each agent’s permission')).toBe(ownSupported) + if (ownSupported) { + expect(container.querySelector('button[aria-label^="Permissions "]')).toBeNull() + expect( + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Settings → Agents' + ) + ).toBeDefined() + } } ) diff --git a/src/renderer/src/components/settings/ChatBehaviorSetting.tsx b/src/renderer/src/components/settings/ChatBehaviorSetting.tsx index 8be61dbe71a..c8cd11d49ee 100644 --- a/src/renderer/src/components/settings/ChatBehaviorSetting.tsx +++ b/src/renderer/src/components/settings/ChatBehaviorSetting.tsx @@ -2,7 +2,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { - useChatSettingsTypedPermissions, + useChatSettingsOwnPermissions, useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' import { useAppStore } from '../../store' @@ -29,7 +29,7 @@ export function ChatBehaviorSetting({ updateSettings, forceVisible = false }: ChatBehaviorSettingProps): React.JSX.Element | null { - const typedPermissions = useChatSettingsTypedPermissions() + const typedPermissions = useChatSettingsOwnPermissions() const query = useAppStore((state) => state.settingsSearchQuery) // A host without the capability ignores the preference; the switch would do nothing. const hostQueuesMessages = useStructuredAgentSessionHostQueuesMessages(STRUCTURED_CHAT_HOST) diff --git a/src/renderer/src/components/settings/ChatPermissionSetting.test.tsx b/src/renderer/src/components/settings/ChatPermissionSetting.test.tsx index 2fee837c365..9efa5cc3395 100644 --- a/src/renderer/src/components/settings/ChatPermissionSetting.test.tsx +++ b/src/renderer/src/components/settings/ChatPermissionSetting.test.tsx @@ -1,19 +1,53 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, 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: '' }) +const mocks = vi.hoisted(() => ({ + web: false, + capability: 'unsupported', + call: vi.fn(), + supports: vi.fn(async () => true), + contacts: new Set<() => void>(), + environments: [{ id: 'host', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-1' }] })) +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web })) +vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ + useStructuredAgentSessionOwnPermissionState: () => mocks.capability +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.call, + runtimeEnvironmentSupportsCapability: mocks.supports +})) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (_id: string, listener: () => void) => { + mocks.contacts.add(listener) + return () => mocks.contacts.delete(listener) + } +})) +vi.mock('../../store', () => ({ + useAppStore: ( + selector: (value: { + settingsSearchQuery: string + runtimeEnvironments: typeof mocks.environments + }) => unknown + ) => selector({ settingsSearchQuery: '', runtimeEnvironments: mocks.environments }) +})) +import { mutateHostAgentLaunchSettings } from './agent-launch-settings-transport' +import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings' +beforeEach(() => { + mocks.web = false + mocks.capability = 'unsupported' + mocks.call.mockReset() + mocks.environments = [{ id: 'host', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-1' }] +}) 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', + 'offers the legacy new-chat default only for a host value %s', (mode) => { const { container } = render( @@ -36,3 +70,108 @@ it.each([undefined, 'ask', 'full-access'] as const)( } } ) + +it('shows the paired host permission and follows mutations, reconnects and re-pair ownership', async () => { + mocks.web = true + mocks.capability = 'supported' + mocks.call.mockResolvedValue({ + settings: projectAgentLaunchSettings({ agentPermissionDefault: 'own' }) + }) + const element = () => ( + + + + ) + const view = render(element()) + const claudeRow = () => screen.getByText('Claude').parentElement?.parentElement + const expectClaude = async (label: string) => { + await waitFor(() => { + const row = claudeRow() + if (!row) { + throw new Error('missing Claude row') + } + expect(within(row).getByText(label)).toBeTruthy() + }) + } + await expectClaude('Own settings') + expect(mocks.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'host' }, + 'settings.getAgentLaunch', + undefined, + expect.objectContaining({ + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-1' + }) + ) + mocks.call.mockResolvedValue({ + settings: projectAgentLaunchSettings({ agentPermissionDefault: 'ask' }) + }) + await act(async () => { + await mutateHostAgentLaunchSettings( + { environmentId: 'host', pairingRevision: 7 }, + { type: 'permission', choice: 'ask' } + ) + }) + await expectClaude('Ask for approval') + mocks.call.mockResolvedValue({ + settings: projectAgentLaunchSettings({ agentPermissionDefault: 'accept-edits' }) + }) + await act(async () => { + mocks.contacts.forEach((listener) => listener()) + }) + await expectClaude('Accept edits') + let resolveOld: (value: unknown) => void = () => {} + mocks.call.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve + }) + ) + act(() => { + mocks.contacts.forEach((listener) => listener()) + }) + mocks.environments = [{ id: 'host', createdAt: 1, pairingRevision: 8, runtimeId: 'runtime-2' }] + mocks.call.mockResolvedValue({ + settings: projectAgentLaunchSettings({ agentPermissionDefault: 'auto' }) + }) + view.rerender(element()) + await expectClaude('Approve for me') + await act(async () => { + resolveOld({ settings: projectAgentLaunchSettings({ agentPermissionDefault: 'full-access' }) }) + }) + await expectClaude('Approve for me') +}) + +it('keeps the Agents link disabled until host support is known', () => { + mocks.capability = 'unknown' + const element = ( + + + + ) + const view = render(element) + expect(screen.getByRole('button', { name: 'Settings → Agents' }).hasAttribute('disabled')).toBe( + true + ) + mocks.capability = 'supported' + view.rerender( + + + + ) + expect(screen.getByRole('button', { name: 'Settings → Agents' }).hasAttribute('disabled')).toBe( + false + ) +}) diff --git a/src/renderer/src/components/settings/ChatPermissionSetting.tsx b/src/renderer/src/components/settings/ChatPermissionSetting.tsx index 8c13406e7f5..8ad6e32fc97 100644 --- a/src/renderer/src/components/settings/ChatPermissionSetting.tsx +++ b/src/renderer/src/components/settings/ChatPermissionSetting.tsx @@ -1,12 +1,22 @@ -import { useChatSettingsTypedPermissions } from '@/runtime/structured-agent-session-host-capability' +import { useChatPermissionSettings } from './use-chat-permission-settings' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { AGENT_CHAT_PERMISSION_MODES } from '../../../../shared/agent-chat-permission-mode' +import { + AGENT_CHAT_PERMISSION_MODES, + agentChatPermissionModes, + newChatPermissionModeForSettings +} from '../../../../shared/agent-chat-permission-mode' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' +import { PERMISSION_CHOICE_AGENTS } from '../../../../shared/tui-agent-permission-choice' +import { useAppStore } from '@/store' +import { Button } from '../ui/button' import { NativeChatPermissionModePicker } from '../native-chat/NativeChatPermissionModePicker' +import { nativeChatPermissionModeLabel } from '../native-chat/native-chat-permission-mode-labels' import { SettingsRow } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { getChatPermissionSearchEntry } from './chat-permission-search' +import { translate } from '@/i18n/i18n' -/** This host's mode for new chats; each chat keeps its own once created. */ +/** The host seeds every chat from its agent's one stored permission. */ export function ChatPermissionSetting({ settings, updateSettings, @@ -16,12 +26,14 @@ export function ChatPermissionSetting({ updateSettings: (updates: Partial) => void forceVisible: boolean }): React.JSX.Element | null { - const typedPermissions = useChatSettingsTypedPermissions() - const current = settings.nativeChatPermissionMode - if (current === undefined) { + const { capability, settings: permissionSettings } = useChatPermissionSettings(settings) + const supported = capability === 'supported' + const modern = capability !== 'unsupported' + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + if (settings.nativeChatPermissionMode === undefined) { return null } - const entry = getChatPermissionSearchEntry(typedPermissions) + const entry = getChatPermissionSearchEntry(supported) return ( - updateSettings({ nativeChatPermissionMode, agentNativeChatPermissionModes: {} }) - } - /> + modern ? ( + + ) : ( + updateSettings({ nativeChatPermissionMode: mode })} + /> + ) } /> + {modern + ? PERMISSION_CHOICE_AGENTS.filter((agent) => agentChatPermissionModes(agent)).map( + (agent) => { + const mode = newChatPermissionModeForSettings(agent, permissionSettings) + const label = + mode === 'own' + ? translate('settings.agents.permissionOwnShort', 'Own settings') + : mode + ? nativeChatPermissionModeLabel(mode) + : '' + return ( + + {supported && permissionSettings ? label : '—'} + + } + /> + ) + } + ) + : null} ) } diff --git a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx index 38eda473fb6..cb7787cd85f 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx @@ -1,3 +1,4 @@ +import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-permission-modes-capability' // @vitest-environment happy-dom import { cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -91,6 +92,18 @@ function renderChat( } describe('Chat settings page', () => { + it('shows each agent’s start permission and links to Agents on an own-settings host', () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY]) + renderChat(true, true, false, { + agentPermissionDefault: 'own', + agentPermissionOverrides: { codex: 'auto' } + }) + expect(screen.getByRole('button', { name: 'Settings → Agents' })).toBeDefined() + expect(screen.getByText('Approve for me')).toBeDefined() + expect(screen.getAllByText('Own settings').length).toBeGreaterThan(0) + expect(screen.queryByRole('button', { name: 'Permissions Full access' })).toBeNull() + }) + it('preserves clicked provider choices while queued saves and inventory changes settle', async () => { let latest = getDefaultSettings('/tmp') state.settings = latest diff --git a/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx b/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx index 35b3164d792..eeb91a1673b 100644 --- a/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx +++ b/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' import { projectAgentLaunchSettings, type AgentLaunchSettings @@ -19,13 +20,15 @@ const transport = vi.hoisted(() => ({ mutate: vi.fn(), detection: vi.fn(), auto: vi.fn(), - native: vi.fn() + native: vi.fn(), + own: vi.fn() })) vi.mock('./agent-launch-settings-transport', () => ({ readHostAgentLaunchSettings: transport.read, mutateHostAgentLaunchSettings: transport.mutate, hostSupportsAutoPermissions: transport.auto, - hostSupportsNativeChatPermissions: transport.native + hostSupportsNativeChatPermissions: transport.native, + hostSupportsOwnChatPermissions: transport.own })) vi.mock('@/hooks/useDetectedAgents', () => ({ useDetectedAgents: (target: unknown) => { @@ -65,9 +68,29 @@ beforeEach(() => { transport.detection.mockClear() transport.auto.mockReset().mockResolvedValue(true) transport.native.mockReset().mockResolvedValue(true) + transport.own.mockReset().mockResolvedValue(false) }) describe('host launch settings form', () => { + it.each([false, true])( + 'describes Own for the selected host capability: %s', + async (supported) => { + transport.own.mockResolvedValue(supported) + transport.read.mockResolvedValue( + projectAgentLaunchSettings({ agentPermissionDefault: 'own' }) + ) + render(pane(17)) + await screen.findByText("Each agent's own settings") + await userEvent.setup().click(screen.getByRole('button', { name: 'Permissions' })) + await screen.findByText( + supported + ? 'Terminals and chats follow the agent’s own permission settings.' + : 'Orca adds no permission flag in terminals; chats use Settings → Chat.' + ) + expect(transport.own).toHaveBeenCalledWith({ environmentId: 'ssh-host', pairingRevision: 17 }) + } + ) + it('shows resolved launch defaults in a collapsed legacy host row', async () => { transport.read.mockResolvedValue(projectAgentLaunchSettings({ agentDefaultArgs: {} })) render(pane(17)) diff --git a/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx b/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx index 0b8422b44a6..b0faa0aed27 100644 --- a/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx +++ b/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx @@ -13,6 +13,7 @@ import { readHostAgentLaunchSettings, hostSupportsAutoPermissions, hostSupportsNativeChatPermissions, + hostSupportsOwnChatPermissions, type AgentLaunchSettingsOwner } from './agent-launch-settings-transport' import { Button } from '../ui/button' @@ -25,6 +26,7 @@ type ReadState = settings: AgentLaunchSettings autoSupported: boolean nativeChatPermissionsSupported: boolean + chatOwnSupported: boolean } export function HostAgentLaunchSettings({ @@ -54,15 +56,23 @@ export function HostAgentLaunchSettings({ controllerRef.current = controller setRead({ kind: 'loading' }) try { - const [settings, autoSupported, nativeChatPermissionsSupported] = await Promise.all([ - readHostAgentLaunchSettings(owner, controller.signal), - hostSupportsAutoPermissions(owner), - hostSupportsNativeChatPermissions(owner) - ]) + const [settings, autoSupported, nativeChatPermissionsSupported, chatOwnSupported] = + await Promise.all([ + readHostAgentLaunchSettings(owner, controller.signal), + hostSupportsAutoPermissions(owner), + hostSupportsNativeChatPermissions(owner), + hostSupportsOwnChatPermissions(owner) + ]) if (!controller.signal.aborted) { setRead( settings - ? { kind: 'ready', settings, autoSupported, nativeChatPermissionsSupported } + ? { + kind: 'ready', + settings, + autoSupported, + nativeChatPermissionsSupported, + chatOwnSupported + } : { kind: 'unsupported' } ) } @@ -95,7 +105,8 @@ export function HostAgentLaunchSettings({ kind: 'ready', settings, autoSupported: read.autoSupported, - nativeChatPermissionsSupported: read.nativeChatPermissionsSupported + nativeChatPermissionsSupported: read.nativeChatPermissionsSupported, + chatOwnSupported: read.chatOwnSupported }) } } catch { @@ -144,6 +155,7 @@ export function HostAgentLaunchSettings({ renderPermissions={renderPermissions} autoSupported={read.autoSupported} nativeChatPermissionsSupported={read.nativeChatPermissionsSupported} + chatOwnSupported={read.chatOwnSupported} refreshSettings={() => void refresh()} /> diff --git a/src/renderer/src/components/settings/agent-launch-settings-transport.ts b/src/renderer/src/components/settings/agent-launch-settings-transport.ts index 8085267a867..0675d906e24 100644 --- a/src/renderer/src/components/settings/agent-launch-settings-transport.ts +++ b/src/renderer/src/components/settings/agent-launch-settings-transport.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-permission-modes-capability' import type { AgentLaunchSettings, AgentLaunchSettingsMutation @@ -119,3 +120,10 @@ export function hostSupportsNativeChatPermissions( AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY ) } + +export function hostSupportsOwnChatPermissions(owner: AgentLaunchSettingsOwner): Promise { + return runtimeEnvironmentSupportsCapability( + owner.environmentId, + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY + ) +} diff --git a/src/renderer/src/components/settings/chat-permission-search.ts b/src/renderer/src/components/settings/chat-permission-search.ts index d2b952c1eae..45b4973354d 100644 --- a/src/renderer/src/components/settings/chat-permission-search.ts +++ b/src/renderer/src/components/settings/chat-permission-search.ts @@ -5,10 +5,9 @@ import type { SettingsSearchEntry } from './settings-search' const getTypedEntry = createLocalizedCatalog((): SettingsSearchEntry => ({ targetSectionId: 'chat-permissions', title: translate('settings.chat.permissionsTitle', 'Permissions for new chats'), - // Temporary until chats can run an agent's own settings. description: translate( 'settings.chat.permissionsDescriptionOwnAgents', - 'New chats of agents set to their own settings in Settings → Agents. Other agents start chats in their permission there.' + 'New chats follow each agent’s permission in Settings → Agents.' ) })) diff --git a/src/renderer/src/components/settings/use-chat-permission-settings.ts b/src/renderer/src/components/settings/use-chat-permission-settings.ts new file mode 100644 index 00000000000..142f317ce6e --- /dev/null +++ b/src/renderer/src/components/settings/use-chat-permission-settings.ts @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { AgentLaunchSettings } from '../../../../shared/agent-launch-settings' +import { isWebClientLocation } from '@/lib/web-client-location' +import { useAppStore } from '@/store' +import { useStructuredAgentSessionOwnPermissionState } from '@/runtime/structured-agent-session-host-capability' +import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' +import { + readHostAgentLaunchSettings, + subscribeHostAgentLaunchSettings +} from './agent-launch-settings-transport' + +export function useChatPermissionSettings(settings: GlobalSettings) { + const web = isWebClientLocation() + // The web preload exposes only its active paired environment in this catalog. + const environment = useAppStore((state) => (web ? state.runtimeEnvironments[0] : undefined)) + const environmentId = environment?.id + const pairingRevision = environment?.pairingRevision ?? environment?.createdAt + const runtimeId = environment?.runtimeId ?? undefined + const capability = useStructuredAgentSessionOwnPermissionState( + web ? (environmentId ? { kind: 'environment', environmentId } : null) : { kind: 'local' } + ) + const key = JSON.stringify([environmentId, pairingRevision, runtimeId]) + const [answer, setAnswer] = useState<{ + key: string + settings: AgentLaunchSettings | null + } | null>(null) + useEffect(() => { + if (!environmentId || capability !== 'supported') { + return + } + const owner = { environmentId, pairingRevision, runtimeId } + let controller = new AbortController() + const refresh = (): void => { + controller.abort() + const readController = new AbortController() + controller = readController + void readHostAgentLaunchSettings(owner, readController.signal).then( + (value) => { + if (!readController.signal.aborted) { + setAnswer({ key, settings: value }) + } + }, + () => { + if (!readController.signal.aborted) { + setAnswer({ key, settings: null }) + } + } + ) + } + const unsubscribe = subscribeHostAgentLaunchSettings((changedOwner) => { + if ( + changedOwner.environmentId === environmentId && + changedOwner.pairingRevision === pairingRevision + ) { + refresh() + } + }) + const disconnect = subscribeRuntimeHostContactRegained(environmentId, refresh) + refresh() + return () => { + controller.abort() + unsubscribe() + disconnect() + } + }, [capability, environmentId, pairingRevision, runtimeId, key]) + const permission = answer?.key === key ? answer.settings?.agentPermission : null + return { + capability, + settings: web + ? permission + ? { + agentPermissionDefault: permission.default, + agentPermissionOverrides: permission.overrides + } + : null + : settings + } +} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 2b4555223e9..c76b3a67285 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -20,7 +20,7 @@ import { defaultScopeHost } from '@/lib/default-creation-host' import { useAppStore } from '@/store' import { useProjectHostSetupProjection } from '@/store/selectors' import { - useChatSettingsTypedPermissions, + useChatSettingsOwnPermissions, useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' @@ -126,7 +126,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { }) ) const nativeChatEnabled = settings?.experimentalNativeChat === true - const typedChatPermissions = useChatSettingsTypedPermissions() + const typedChatPermissions = useChatSettingsOwnPermissions() const hostQueuesMessages = useStructuredAgentSessionHostQueuesMessages({ kind: 'local' }) const isMac = isMacUserAgent() const isWindows = isWindowsUserAgent() diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d852aa58413..89f93636255 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3464,6 +3464,7 @@ "agents": { "loadingHost": "Loading agent settings…", "permissionChoiceDescription": "Applies where each agent supports this mode. Change one agent below.", + "permissionEachOwnDescription": "Orca adds nothing; each CLI uses its own config", "permissionResetsOwnChoices": "Choosing a mode here resets the agents marked as set separately." }, "browser": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 90200eece98..c950adc085e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -249,6 +249,7 @@ }, "settings": { "chat": { + "permissionsAgentsLink": "Settings → Agents", "permissionsTitle": "Permissions for new chats", "permissionsDescription": "New chats only. Agent-specific defaults are in Settings → Agents.", "description": "Choose how chats look and behave.", @@ -279,7 +280,7 @@ "antigravityAccount": "Account", "antigravitySignInNeeded": "Chats need an Antigravity sign-in, separate from your agy terminal account." }, - "permissionsDescriptionOwnAgents": "New chats of agents set to their own settings in Settings → Agents. Other agents start chats in their permission there." + "permissionsDescriptionOwnAgents": "New chats follow each agent’s permission in Settings → Agents." }, "terminal": { "optionLayoutAlt": "ABC or U.S. — Option sends Alt/Esc sequences", @@ -516,7 +517,8 @@ "permissionFromArgsTerminals": "{{agent}} set to {{mode}} in terminals.", "permissionOwnAgent": "Use {{agent}}'s own settings", "permissionOwnAll": "Agents' own settings", - "permissionOwnDescription": "Orca adds no permission flag in terminals; chats use Settings → Chat.", + "permissionOwnLegacyDescription": "Orca adds no permission flag in terminals; chats use Settings → Chat.", + "permissionOwnDescription": "Terminals and chats follow the agent’s own permission settings.", "permissionChoiceDescription": "Applies where each agent supports this mode. Change one agent below.", "permissionOwnShort": "Own settings", "permissionWithoutAuto": "Agents without Approve for me", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 077ddec2fe5..a0b335d6c25 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -23,6 +23,7 @@ }, "settings": { "chat": { + "permissionsAgentsLink": "Configuración → Agentes", "permissionsTitle": "Permisos para chats nuevos", "permissionsDescription": "Solo para chats nuevos. Los valores predeterminados de cada agente están en Configuración → Agentes.", "description": "Elige cómo se ven y se comportan los chats.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6a0e4dac125..5a23825e193 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -163,6 +163,7 @@ }, "settings": { "chat": { + "permissionsAgentsLink": "Paramètres → Agents", "permissionsTitle": "Autorisations pour les nouveaux chats", "permissionsDescription": "Uniquement pour les nouveaux chats. Les paramètres par défaut propres à chaque agent se trouvent dans Paramètres → Agents.", "description": "Choisissez l’apparence et le comportement des chats.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6884bcf86a9..a1d4c57b63a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -23,6 +23,7 @@ }, "settings": { "chat": { + "permissionsAgentsLink": "設定 → Agent", "permissionsTitle": "新しいチャットの権限", "permissionsDescription": "新しいチャットにのみ適用されます。Agentごとのデフォルト設定は、設定 → Agent にあります。", "description": "チャットの外観と動作を選びます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 03a1f489429..a3f567bb6e9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -23,6 +23,7 @@ }, "settings": { "chat": { + "permissionsAgentsLink": "설정 → 에이전트", "permissionsTitle": "새 채팅의 권한", "permissionsDescription": "새 채팅에만 적용됩니다. 에이전트별 기본값은 설정 → 에이전트에 있습니다.", "description": "채팅의 모양과 동작 방식을 선택하세요.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 217896a0cf4..b3655e3c350 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -774,6 +774,7 @@ } }, "chat": { + "permissionsAgentsLink": "Configurações → Agentes", "permissionsTitle": "Permissões para novos chats", "permissionsDescription": "Somente novos chats. Os padrões de cada agente estão em Configurações → Agentes.", "description": "Escolha a aparência e o comportamento dos chats.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1fbd0ab83d4..83c9acb5ddd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -23,6 +23,7 @@ }, "settings": { "chat": { + "permissionsAgentsLink": "设置 → 智能体", "permissionsTitle": "新聊天的权限", "permissionsDescription": "仅适用于新聊天。各智能体的默认设置位于设置 → 智能体。", "description": "选择聊天的外观和行为方式。", diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx new file mode 100644 index 00000000000..a41dd2bdd0f --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type * as HostCapability from './structured-agent-session-host-capability' +// The separate whole-host publication contract covers the status feed; isolate capability display here. +vi.mock('../components/native-chat/StructuredAgentSessionStatusBridge', () => ({ + useStructuredAgentSessionStatusSummary: () => ({ summary: null, observation: 0 }) +})) +// Existing non-Own action gates have their own unchanged request/recovery contracts. +vi.mock('./structured-agent-session-host-capability', async (original) => ({ + ...(await original()), + useStructuredAgentSessionHostCapability: () => false +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('./structured-agent-session-client', () => ({ + callStructuredAgentSession: async () => ({ + models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }], + current: { model: 'model-a', permissionMode: 'ask', reportedPermissionMode: 'ask' } + }), + subscribeStructuredAgentSessionStatus: async () => ({ unsubscribe: () => {} }) +})) +import { useStructuredAgentSessionOptions } from '../components/native-chat/use-structured-agent-session-options' +import { useAppStore } from '@/store' +import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../shared/agent-session-permission-modes-capability' +import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' +import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner' +import { useStructuredAgentSessionOwnPermissionState } from './structured-agent-session-host-capability' + +const owners: RuntimeHostStatusOwner[] = [] +afterEach(() => { + owners.splice(0).forEach((owner) => owner.dispose()) + cleanup() + clearRuntimeCompatibilityCacheForTests() + vi.unstubAllGlobals() +}) + +it.each([10, 100])( + 'enables %s mounted consumers from the existing connection owner', + async (count) => { + useAppStore.setState({ + runtimeEnvironments: [ + { + id: 'host', + name: 'host', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'host' + } + ], + runtimeStatusByEnvironmentId: new Map() + }) + let response = Promise.withResolvers>() + const request = vi.fn(() => response.promise) + const owner = new RuntimeHostStatusOwner({ + environmentId: 'host', + pairingRevision: 1, + request, + verified: () => false, + publish: (snapshot) => useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot) + }) + owners.push(owner) + const call = vi.fn() + vi.stubGlobal('window', { document, api: { runtimeEnvironments: { call } } }) + let renderCalls = 0 + const target = { kind: 'environment', environmentId: 'host' } as const + const views = Array.from({ length: count }, (_, index) => + renderHook(() => { + const before = call.mock.calls.length + const options = useStructuredAgentSessionOptions({ + agent: 'claude', + sessionId: `composer-${index}`, + target, + fence: 1, + transportEnabled: true, + isVisible: true, + providerVisible: true, + turnId: null, + unloadedTurnRevisions: undefined, + hostOptionStamp: 'model-a|ask', + mutate: vi.fn(async () => null) + }) + const state = useStructuredAgentSessionOwnPermissionState(target) + renderCalls += call.mock.calls.length - before + return { state, picker: options.optionSnapshot.find((row) => row.id === 'permissionMode') } + }) + ) + expect(renderCalls).toBe(0) + expect(call).not.toHaveBeenCalled() + act(() => { + owner.connectionChanged('ready') + owner.activate() + }) + expect(request).toHaveBeenCalledTimes(1) + await waitFor(() => + expect(views.every((view) => view.result.current.picker?.settable === false)).toBe(true) + ) + expect(views.map((view) => view.result.current.state)).toEqual(Array(count).fill('unknown')) + const good = createCompatibleRuntimeStatusResponse('host') + if (!good.ok) { + throw new Error('expected successful fixture') + } + good.result.capabilities = (good.result.capabilities ?? []).filter( + (capability) => capability !== AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY + ) + await act(async () => { + response.resolve(good) + await response.promise + }) + expect(views.map((view) => view.result.current.state)).toEqual(Array(count).fill('unsupported')) + expect(views.every((view) => view.result.current.picker?.settable)).toBe(true) + response = Promise.withResolvers>() + act(() => owner.connectionChanged('disconnected')) + expect(views.map((view) => view.result.current.state)).toEqual(Array(count).fill('unsupported')) + act(() => owner.connectionChanged('ready')) + const upgraded = structuredClone(good) + upgraded.result.capabilities = [ + ...(good.result.capabilities ?? []), + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY + ] + await act(async () => { + response.resolve(upgraded) + await response.promise + }) + expect(request).toHaveBeenCalledTimes(2) + expect(views.map((view) => view.result.current.state)).toEqual(Array(count).fill('supported')) + expect( + views.every((view) => { + const picker = view.result.current.picker + return ( + picker?.settable === true && + picker.valueSource === 'reported' && + picker.description === 'Ask for approval' && + picker.kind.type === 'select' && + picker.kind.currentValue === 'ask' && + picker.kind.choices.some((choice) => choice.value === 'ask') && + !picker.kind.choices.some((choice) => choice.value === 'own') + ) + }) + ).toBe(true) + expect(call).not.toHaveBeenCalled() + } +) diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index d9deb58b996..e8ad57b1530 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -1,5 +1,6 @@ -import { AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY } from '../../../shared/agent-launch-runtime-capability' -import { useEffect, useState } from 'react' +import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../shared/agent-session-permission-modes-capability' +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, @@ -12,19 +13,55 @@ import { import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { ensureLocalRuntimeCapabilities, - readLocalRuntimeCapabilitiesOrUnknown + readLocalRuntimeCapabilitiesOrUnknown, + subscribeLocalRuntimeCapabilityObservation } from './local-runtime-capabilities' import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained' +import { + isRuntimeHostContactRevoked, + lastVerifiedRuntimeStatus +} from '../../../shared/runtime-host-status' export function structuredAgentSessionHostKey(target: RuntimeClientTarget): string { return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } -/** `unknown` until the host has answered, and after a failed probe: a failed probe is not - * evidence about the host. */ +/** Unknown before verification or after revocation; a contact outage retains the known build. */ export type StructuredAgentSessionHostCapabilityState = 'unknown' | 'supported' | 'unsupported' +/** Displays the existing host status; connection lifecycle owns probing. */ +export function useStructuredAgentSessionOwnPermissionState( + target: RuntimeClientTarget | null +): StructuredAgentSessionHostCapabilityState { + const capability = AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY + const environmentId = target?.kind === 'environment' ? target.environmentId : null + const statuses = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const remote = useMemo((): StructuredAgentSessionHostCapabilityState => { + if (environmentId === null) { + return 'unknown' + } + const entry = statuses.get(environmentId) + const status = isRuntimeHostContactRevoked(entry) ? null : lastVerifiedRuntimeStatus(entry) + return status + ? status.capabilities?.includes(capability) + ? 'supported' + : 'unsupported' + : 'unknown' + }, [statuses, environmentId, capability]) + const local = useSyncExternalStore( + subscribeLocalRuntimeCapabilityObservation, + () => localCapabilityState(capability), + () => 'unknown' as const + ) + useEffect(() => { + if (target?.kind === 'local') { + void ensureLocalRuntimeCapabilities() + } + }, [target?.kind]) + return !target ? 'unknown' : environmentId === null ? local : remote +} + /** Whether the session's host advertises `capability`. A failed probe stays `unknown` and a remote * host is asked again each time this client regains contact with it. */ export function useStructuredAgentSessionHostCapabilityState( @@ -159,9 +196,6 @@ export function useStructuredAgentSessionHostEditsQueuedImages( } /** The API runtime owns Chat settings; on web the local API forwards to the active paired host. */ -export function useChatSettingsTypedPermissions(): boolean { - return useStructuredAgentSessionHostCapability( - { kind: 'local' }, - AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY - ) +export function useChatSettingsOwnPermissions(): boolean { + return useStructuredAgentSessionOwnPermissionState({ kind: 'local' }) === 'supported' } diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts index 3a2c96b9ee4..b1518fe0685 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -34,7 +34,9 @@ const done: AgentSessionStatusSummary = { ...owned, sessionId: 'completed', status: 'idle', - hostExecutionOwned: undefined + hostExecutionOwned: undefined, + hostExecutionPhase: undefined, + runtimePermission: null } function subscription(index = 0): Subscription { @@ -81,7 +83,8 @@ describe('structured status feed execution authority lifecycle', () => { expect(feed.getSnapshot().get('running')).toEqual({ ...owned, hostExecutionOwned: undefined, - hostExecutionPhase: undefined + hostExecutionPhase: undefined, + runtimePermission: null }) expect(feed.getSnapshot().get('completed')).toBe(done) subscription().emit({ type: 'status', session: owned }) diff --git a/src/renderer/src/runtime/structured-chat-host-permission.test.ts b/src/renderer/src/runtime/structured-chat-host-permission.test.ts index 600d8dff972..65edae1a849 100644 --- a/src/renderer/src/runtime/structured-chat-host-permission.test.ts +++ b/src/renderer/src/runtime/structured-chat-host-permission.test.ts @@ -38,7 +38,8 @@ beforeEach(() => { vi.clearAllMocks() mocks.supportsCapability.mockResolvedValue(false) mocks.settings.nativeChatPermissionMode = 'ask' - mocks.settings.agentNativeChatPermissionModes = {} + mocks.settings.agentPermissionDefault = undefined + mocks.settings.agentPermissionOverrides = {} }) describe('the permission verdict on a paired host', () => { @@ -81,7 +82,8 @@ describe('the permission verdict on a paired host', () => { it('checks the scoped agent choice instead of a permissive global fallback', async () => { mocks.settings.nativeChatPermissionMode = 'full-access' - mocks.settings.agentNativeChatPermissionModes = { codex: 'auto' } + mocks.settings.agentPermissionDefault = 'full-access' + mocks.settings.agentPermissionOverrides = { codex: 'auto' } mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'codex')).resolves.toEqual({ kind: 'refused', @@ -92,6 +94,29 @@ describe('the permission verdict on a paired host', () => { }) }) + it('delegates Own to the old host without relaxing explicit or unknown requests', async () => { + mocks.settings.agentPermissionDefault = 'own' + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) + mocks.settings.nativeChatPermissionMode = 'full-access' + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'codex')).resolves.toEqual({ + kind: 'allowed' + }) + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'codex', 'own')).resolves.toEqual({ + kind: 'allowed' + }) + await expect( + readStructuredChatHostPermissionVerdict(PAIRED, 'codex', 'future-mode') + ).resolves.toEqual({ kind: 'refused', hostMode: 'full-access' }) + mocks.settings.nativeChatPermissionMode = 'ask' + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'codex')).resolves.toEqual({ + kind: 'allowed' + }) + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: { codex: '' } } }) + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'codex')).resolves.toEqual({ + kind: 'allowed' + }) + }) + it('lets Full access run on that older host: it runs no more than asked for', async () => { mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) @@ -112,9 +137,11 @@ describe('the permission verdict on a paired host', () => { mocks.supportsCapability.mockRejectedValue(new Error('timeout')) mocks.callRuntimeRpc.mockRejectedValue(new Error('timeout')) - await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'claude', 'ask')).resolves.toEqual( - { kind: 'unverified' } - ) + for (const requested of ['ask', 'own']) { + await expect( + readStructuredChatHostPermissionVerdict(PAIRED, 'claude', requested) + ).resolves.toEqual({ kind: 'unverified' }) + } }) it('asks nothing for this machine, or for an agent without chat modes', async () => { diff --git a/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts b/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts index 974b94ea266..931990addb5 100644 --- a/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts +++ b/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts @@ -24,7 +24,7 @@ it.each([ (_name, args, raw, choice) => { // Recorded Goose process environment: ticket 1447, PID 20979, creation validated around capture. const recordedGooseEnv = { GOOSE_MODE: 'auto', QA_GOOSE_NOTE: 'ordinary70a4' } - const stored: Partial = { + const stored: Partial & { agentNativeChatPermissionModes: unknown } = { agentDefaultArgs: { claude: args, codex: '' }, agentDefaultEnv: { goose: recordedGooseEnv }, nativeChatPermissionMode: 'ask', @@ -38,7 +38,10 @@ it.each([ expect(loaded.agentDefaultArgs?.codex).toBe('') expect(resolveTuiAgentLaunchArgs('claude', loaded)).toBe(args) expect(loaded.nativeChatPermissionMode).toBe('ask') - expect(loaded.agentNativeChatPermissionModes).toEqual(stored.agentNativeChatPermissionModes) + expect(loaded).toHaveProperty( + 'agentNativeChatPermissionModes', + stored.agentNativeChatPermissionModes + ) expect(resolveAgentPermission('goose', loaded).choice).toBe('full-access') expect(loaded.agentDefaultEnv?.goose).toEqual({ QA_GOOSE_NOTE: 'ordinary70a4' }) expect(resolveTuiAgentLaunchEnv('goose', loaded)).toEqual(recordedGooseEnv) @@ -46,23 +49,9 @@ it.each([ } ) -it('clears a previous host scoped default when reading settings from a pre-native-sync host', () => { - const cached = { - nativeChatPermissionMode: 'full-access', - agentNativeChatPermissionModes: { codex: 'auto' } - } - const current = { - ...cached, - ...runtimeChatPermissionSetting({ nativeChatPermissionMode: 'ask' }) - } - expect(current).toEqual({ nativeChatPermissionMode: 'ask', agentNativeChatPermissionModes: {} }) -}) - -it('never adds scoped overrides to the legacy settings.update strict schema', () => { - expect( - runtimeChatPermissionSetting( - { nativeChatPermissionMode: 'ask', agentNativeChatPermissionModes: { codex: 'auto' } }, - false - ) - ).toEqual({ nativeChatPermissionMode: 'ask' }) +it('keeps the legacy host-support marker without recreating per-agent chat copies', () => { + expect(runtimeChatPermissionSetting({ nativeChatPermissionMode: 'ask' })).toEqual({ + nativeChatPermissionMode: 'ask' + }) + expect(runtimeChatPermissionSetting({})).toEqual({}) }) 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 5b0a9277b04..e0fcb439696 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -1,7 +1,4 @@ -import { - isAgentChatPermissionMode, - normalizeAgentNativeChatPermissionModes -} from '../../../../shared/agent-chat-permission-mode' +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 { @@ -230,18 +227,9 @@ 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, - includeAgentOverrides = true -): Pick { - return { - ...(isAgentChatPermissionMode(settings.nativeChatPermissionMode) - ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } - : {}), - ...(includeAgentOverrides - ? { - agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes( - settings.agentNativeChatPermissionModes - ) - } - : {}) - } + _includeAgentOverrides = true +): Pick { + return isAgentChatPermissionMode(settings.nativeChatPermissionMode) + ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } + : {} } diff --git a/src/shared/agent-chat-permission-host-support.ts b/src/shared/agent-chat-permission-host-support.ts index 31e31d01646..837b256b939 100644 --- a/src/shared/agent-chat-permission-host-support.ts +++ b/src/shared/agent-chat-permission-host-support.ts @@ -57,7 +57,11 @@ export type ChatPermissionHostVerdict = | { kind: 'allowed' } | { kind: 'refused'; hostMode: AgentChatPermissionMode | null } -/** Older hosts may grant no more access than requested; unknown legacyMode refuses rather than assuming strict permissions. */ +/** + * Whether a chat may start or take a turn on its host. A host that predates per-chat modes is + * allowed for Own delegation or where it grants no more access than `requested`: an unreadable host + * (`legacyMode` undefined) is refused, never assumed strict. + */ export function chatPermissionHostVerdict(args: { agent: string hostAppliesModes: boolean @@ -68,7 +72,10 @@ export function chatPermissionHostVerdict(args: { return { kind: 'allowed' } } const requested = isAgentChatPermissionMode(args.requested) ? args.requested : 'ask' - if (args.legacyMode !== undefined && accessRank(args.legacyMode) <= accessRank(requested)) { + if ( + args.legacyMode !== undefined && + (args.requested === 'own' || accessRank(args.legacyMode) <= accessRank(requested)) + ) { return { kind: 'allowed' } } return { kind: 'refused', hostMode: args.legacyMode ?? null } diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index 14a3f2fbe85..e18fc5faa35 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -5,12 +5,9 @@ import { 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')! @@ -81,86 +78,7 @@ describe('the mode a chat runs', () => { }) }) -describe('the new-chat setting as loaded', () => { - const seed = (terminal: Parameters[1]) => - loadedChatPermissionSetting(undefined, terminal, 'darwin') - const yoloWithArgs = (args: { claude?: string; codex?: string; grok?: string }) => { - const terminal = applyAgentPermissionMode({ mode: 'yolo' }) - Object.assign(terminal.agentDefaultArgs, args) - return terminal - } - - it.each([ - ['yolo', 'full-access'], - ['manual', 'ask'] - ] as const)('seeds %s Agent Permissions as %s', (mode, expected) => { - expect(seed(applyAgentPermissionMode({ mode }))).toBe(expected) - }) - - it('seeds Full access when Claude and Codex carry their bypass flags among other arguments', () => { - expect( - seed( - yoloWithArgs({ - claude: '--dangerously-skip-permissions --model opus', - codex: '--model gpt-5 --dangerously-bypass-approvals-and-sandbox' - }) - ) - ).toBe('full-access') - }) - - it('reads only Claude and Codex, not the other agents', () => { - expect(seed(yoloWithArgs({ grok: '' }))).toBe('full-access') - const othersManual = applyAgentPermissionMode({ mode: 'manual' }) - Object.assign(othersManual.agentDefaultArgs, { - claude: '--dangerously-skip-permissions', - codex: '--dangerously-bypass-approvals-and-sandbox' - }) - expect(seed(othersManual)).toBe('full-access') - }) - - it('seeds Ask when Claude or Codex launches without its bypass flag', () => { - expect(seed(yoloWithArgs({ claude: '' }))).toBe('ask') - expect(seed(yoloWithArgs({ codex: '--model gpt-5' }))).toBe('ask') - }) - - // Manual leaves custom arguments alone, so the all-agent summary can still be Mixed. - it('seeds Ask after Manual even when another agent keeps custom arguments', () => { - const custom = yoloWithArgs({ codex: '--model gpt-5', grok: '--model grok-4' }) - expect(seed(applyAgentPermissionMode({ mode: 'manual', ...custom }))).toBe('ask') - }) - - it('seeds a fresh profile from the shipped Yolo defaults', () => { - expect(seed(getDefaultSettings('/tmp'))).toBe('full-access') - expect(getDefaultSettings('/tmp').nativeChatPermissionMode).toBe('full-access') - }) - - it('seeds Auto when both Claude and Codex already use the automatic preset', () => { - expect(seed(applyAgentPermissionMode({ mode: 'auto' }))).toBe('auto') - expect(seed({ agentDefaultArgs: { claude: '--permission-mode auto', codex: '' } })).toBe('ask') - expect( - loadedChatPermissionSetting( - 'accept-edits', - applyAgentPermissionMode({ mode: 'auto' }), - 'darwin' - ) - ).toBe('accept-edits') - }) - - it.each(AGENT_CHAT_PERMISSION_MODES)( - 'keeps a saved %s whatever Agent Permissions say', - (mode) => { - for (const terminal of ['manual', 'yolo'] as const) { - expect( - loadedChatPermissionSetting(mode, applyAgentPermissionMode({ mode: terminal }), 'darwin') - ).toBe(mode) - } - } - ) - - it.each(['future', null, 42, 'bypass'])('reads an unknown saved %s as Ask', (value) => { - expect(loadedChatPermissionSetting(value, getDefaultSettings('/tmp'), 'darwin')).toBe('ask') - }) - +describe('the model permission catalog', () => { // 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 = [ diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 2eaa968b842..754aed17ba7 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -1,9 +1,6 @@ import type { GlobalSettings } from './global-settings-types' -import { - resolvedTuiAgentArgsBypassPermissions, - resolveTuiAgentLaunchArgs -} from './tui-agent-launch-defaults' -import { AUTO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import { resolvedTuiAgentArgsBypassPermissions } from './tui-agent-launch-defaults' +import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' import { hasTypedAgentPermission, resolveAgentPermission, @@ -13,8 +10,14 @@ import { isTuiAgent } from './tui-agent-config' import type { TuiAgent } from './tui-agent' /** A chat's own permission choice. Saved in chat records and settings: never rename a value. */ +export type AgentChatPermissionChoice = 'own' | AgentChatPermissionMode + export type AgentChatPermissionMode = 'ask' | 'accept-edits' | 'auto' | 'full-access' +export function isAgentChatPermissionChoice(value: unknown): value is AgentChatPermissionChoice { + return value === 'own' || isAgentChatPermissionMode(value) +} + /** Picker order: least to most access. */ export const AGENT_CHAT_PERMISSION_MODES = [ 'ask', @@ -50,10 +53,21 @@ export { } from './agent-chat-permission-support' /** The saved choice if this agent offers it; anything else asks, never grants more access. */ +export function savedChatPermissionMode( + support: AgentChatPermissionModeSupport, + intent: AgentChatPermissionMode +): AgentChatPermissionMode export function savedChatPermissionMode( support: AgentChatPermissionModeSupport, intent: unknown -): AgentChatPermissionMode { +): AgentChatPermissionChoice +export function savedChatPermissionMode( + support: AgentChatPermissionModeSupport, + intent: unknown +): AgentChatPermissionChoice { + if (intent === 'own') { + return 'own' + } return support.modes.find((mode) => mode === intent) ?? 'ask' } @@ -84,7 +98,7 @@ export function effectiveChatPermissionMode( support: AgentChatPermissionModeSupport, intent: unknown, autoSupported: boolean -): AgentChatPermissionMode { +): AgentChatPermissionChoice { const mode = savedChatPermissionMode(support, intent) return mode === 'auto' && support.autoNeedsModelSupport && !autoSupported ? 'ask' : mode } @@ -101,62 +115,34 @@ export function offeredChatPermissionModes( export function newChatPermissionMode( agent: string, setting: unknown -): AgentChatPermissionMode | null { +): AgentChatPermissionChoice | null { const support = agentChatPermissionModes(agent) return support ? savedChatPermissionMode(support, setting) : null } export type NewChatPermissionSettings = AgentPermissionSettings & - Partial> + Partial> -/** New chats inherit agent permissions; own-settings mode retains Chat defaults until chats can run agent config. */ +/** The four-mode value shipped clients understand; delegation has no legacy representation. */ +export function publishedLegacyChatPermissionMode( + settings: NewChatPermissionSettings +): AgentChatPermissionMode { + const choice = hasTypedAgentPermission(settings) + ? settings.agentPermissionDefault + : settings.nativeChatPermissionMode + return isAgentChatPermissionMode(choice) ? choice : 'ask' +} + +/** New chats use the same permission as the agent's terminals. */ export function newChatPermissionModeForSettings( agent: string, settings: NewChatPermissionSettings | null | undefined -): AgentChatPermissionMode | null { +): AgentChatPermissionChoice | null { const support = agentChatPermissionModes(agent) if (support && isTuiAgent(agent) && hasTypedAgentPermission(settings)) { - const { choice } = resolveAgentPermission(agent, settings) - if (choice !== 'own') { - return savedChatPermissionMode(support, choice) - } + return savedChatPermissionMode(support, resolveAgentPermission(agent, settings).choice) } - const overrides = settings?.agentNativeChatPermissionModes - const override = overrides && Object.getOwnPropertyDescriptor(overrides, agent)?.value - return newChatPermissionMode(agent, override ?? settings?.nativeChatPermissionMode) -} - -export function normalizeAgentNativeChatPermissionModes( - value: unknown -): NonNullable { - if (!value || typeof value !== 'object') { - return {} - } - return Object.fromEntries( - Object.entries(value).filter(([agent, mode]) => - agentChatPermissionModes(agent)?.modes.some((offered) => offered === mode) - ) - ) -} - -/** Seeded once when absent, then stored; an unknown value asks. */ -export function loadedChatPermissionSetting( - saved: unknown, - terminal: Partial>, - platform: NodeJS.Platform -): AgentChatPermissionMode { - if (saved !== undefined) { - return isAgentChatPermissionMode(saved) ? saved : 'ask' - } - const automatic = (agent: 'claude' | 'codex'): boolean => - resolveTuiAgentLaunchArgs(agent, terminal).trim() === AUTO_TUI_AGENT_ARGS[agent] - if (automatic('claude') && automatic('codex')) { - return 'auto' - } - // Seed from each agent's arguments; the all-agent summary can reflect unrelated overrides. - const bypasses = (agent: 'claude' | 'codex'): boolean => - resolvedTuiAgentArgsBypassPermissions(agent, terminal, platform) - return bypasses('claude') && bypasses('codex') ? 'full-access' : 'ask' + return newChatPermissionMode(agent, settings?.nativeChatPermissionMode) } /** Pre-mode chats inherit argument bypass as Full access; agents without bypass use the new-chat setting. */ diff --git a/src/shared/agent-launch-settings-permissions.test.ts b/src/shared/agent-launch-settings-permissions.test.ts index 926f2663239..963209c582d 100644 --- a/src/shared/agent-launch-settings-permissions.test.ts +++ b/src/shared/agent-launch-settings-permissions.test.ts @@ -1,77 +1,28 @@ -import { describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings' import { structuredLaunchSeedOptions } from './native-chat-session-option-defaults' -describe('agent permission presets and new chats', () => { - it.each([ - ['yolo', 'full-access'], - ['manual', 'ask'] - ] as const)('uses %s as the terminal preset and %s as the new chat default', (mode, expected) => { - const updates = agentLaunchSettingsMutationUpdates( - { nativeChatPermissionMode: 'accept-edits' }, - { type: 'permissions', syncNativeChat: true, mode }, - 'linux' - ) - expect(updates.nativeChatPermissionMode).toBe(expected) - for (const agent of ['claude', 'codex'] as const) { - expect(structuredLaunchSeedOptions(updates, agent)).toEqual({ permissionMode: expected }) - } - }) - - it.each(['ask', 'full-access', 'accept-edits'] as const)( - 'preserves the %s shared default when enabling Auto', - (nativeChatPermissionMode) => { - const before = { - nativeChatPermissionMode, - agentNativeChatPermissionModes: { grok: 'ask' as const } - } - const updates = agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', syncNativeChat: true, mode: 'auto' }, - 'linux' - ) - const after = { ...before, ...updates } - expect(updates).not.toHaveProperty('nativeChatPermissionMode') - expect(after.agentNativeChatPermissionModes).toEqual({ - grok: 'ask', - claude: 'auto', - codex: 'auto' - }) - expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'auto' }) - expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) - expect(structuredLaunchSeedOptions(after, 'grok')).toEqual({ permissionMode: 'ask' }) - expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ - permissionMode: nativeChatPermissionMode === 'full-access' ? 'full-access' : 'ask' - }) - expect(structuredLaunchSeedOptions(after, 'pi')).toBeUndefined() - } - ) - - it('keeps the separate chat default when editing terminal arguments', () => { - const updates = agentLaunchSettingsMutationUpdates( - { nativeChatPermissionMode: 'accept-edits' }, - { type: 'arguments', agent: 'claude', value: '--permission-mode auto' }, - 'linux' - ) - expect(updates.agentDefaultArgs?.claude).toBe('--permission-mode auto') - expect(updates).not.toHaveProperty('nativeChatPermissionMode') - }) -}) - -it.each(['manual', 'yolo', 'auto'] as const)( - 'keeps legacy %s mutations terminal-only on native-capable hosts', - (mode) => { +it.each([ + ['yolo', 'full-access'], + ['manual', 'own'], + ['auto', 'auto'] +] as const)( + 'legacy %s permission updates feed the single choice and new chats', + (mode, expected) => { const before = { - nativeChatPermissionMode: 'accept-edits' as const, - agentNativeChatPermissionModes: { codex: 'auto' as const } + agentPermissionDefault: 'full-access' as const, + nativeChatPermissionMode: 'accept-edits' as const } const updates = agentLaunchSettingsMutationUpdates( before, { type: 'permissions', mode }, 'linux' ) - expect(updates).not.toHaveProperty('nativeChatPermissionMode') + const after = { ...before, ...updates } + expect(after.nativeChatPermissionMode).toBe('accept-edits') + for (const agent of ['claude', 'codex'] as const) { + expect(structuredLaunchSeedOptions(after, agent)).toEqual({ permissionMode: expected }) + } expect(updates).not.toHaveProperty('agentNativeChatPermissionModes') - expect({ ...before, ...updates }.nativeChatPermissionMode).toBe('accept-edits') } ) diff --git a/src/shared/agent-launch-settings.ts b/src/shared/agent-launch-settings.ts index b102e1fd809..79b7c2dd42d 100644 --- a/src/shared/agent-launch-settings.ts +++ b/src/shared/agent-launch-settings.ts @@ -1,11 +1,6 @@ import { agentEnvironmentMutationUpdates, choiceUpdate } from './agent-launch-environment-updates' import type { GlobalSettings } from './global-settings-types' -import { - agentChatPermissionModes, - type AgentChatPermissionMode -} from './agent-chat-permission-mode' import type { TuiAgent } from './tui-agent' -import { isTuiAgent } from './tui-agent-config' import { buildDefaultAgentSettingsUpdate } from './default-agent-settings' import { buildAgentAvailabilitySettingsUpdate, @@ -47,7 +42,6 @@ export type AgentLaunchSettingsSource = Partial< | 'agentPermissionDefault' | 'agentPermissionOverrides' | 'nativeChatPermissionMode' - | 'agentNativeChatPermissionModes' > > @@ -79,6 +73,7 @@ export type AgentLaunchSettingsMutation = agent?: TuiAgent /** Change only agents Auto doesn't cover. Sent only to hosts with the Auto capability. */ exceptAuto?: true + /** Accepted from legacy clients; the single permission choice already drives chats. */ syncNativeChat?: true } /** Hosts with the stored permission. No agent: all agents; `withoutAuto`: agents without Auto. */ @@ -134,28 +129,6 @@ export function projectAgentLaunchSettings( } } -function nativeChatModesForPreset( - settings: AgentLaunchSettingsSource, - mode: AgentChatPermissionMode, - autoOnly: boolean -): Partial> { - return Object.fromEntries( - [...Object.keys(YOLO_TUI_AGENT_ARGS), ...Object.keys(YOLO_TUI_AGENT_ENV)] - .filter((agent): agent is TuiAgent => isTuiAgent(agent)) - .filter( - (agent) => - agent in AUTO_TUI_AGENT_ARGS === autoOnly && - agentChatPermissionModes(agent) && - resolveTuiAgentPermissionMode({ - agent, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings) - }) !== 'mixed' - ) - .map((agent) => [agent, mode]) - ) -} - export function agentLaunchSettingsMutationUpdates( settings: AgentLaunchSettingsSource, mutation: AgentLaunchSettingsMutation, @@ -179,8 +152,6 @@ export function agentLaunchSettingsMutationUpdates( } return permissionUpdates(settings, mutation) case 'permissions': { - const nativeMode = - mutation.mode === 'yolo' ? 'full-access' : mutation.mode === 'auto' ? 'auto' : 'ask' const typed = hasTypedAgentPermission(settings) const terminal = (args: Parameters[0]) => typed ? legacyPermissionsUpdates(settings, mutation) : applyAgentPermissionMode(args) @@ -202,53 +173,16 @@ export function agentLaunchSettingsMutationUpdates( ) { throw new Error('Edit custom launch arguments before choosing a permission mode') } - return { - ...terminal({ ...settings, mode: mutation.mode, agent }), - ...(mutation.syncNativeChat && agentChatPermissionModes(agent) - ? { - agentNativeChatPermissionModes: { - ...settings.agentNativeChatPermissionModes, - [agent]: nativeMode - } - } - : {}) - } + return terminal({ ...settings, mode: mutation.mode, agent }) } - if (mutation.exceptAuto) { - if (mutation.mode === 'auto') { - throw new Error('Auto cannot apply to agents without automatic permission review') - } - return { - ...terminal({ ...settings, mode: mutation.mode, exceptAuto: true }), - ...(mutation.syncNativeChat - ? { - agentNativeChatPermissionModes: { - ...settings.agentNativeChatPermissionModes, - ...nativeChatModesForPreset(settings, nativeMode, false) - } - } - : {}) - } - } - if (mutation.mode === 'auto') { - return { - ...terminal({ ...settings, mode: mutation.mode }), - ...(mutation.syncNativeChat - ? { - agentNativeChatPermissionModes: { - ...settings.agentNativeChatPermissionModes, - ...nativeChatModesForPreset(settings, nativeMode, true) - } - } - : {}) - } - } - return { - ...terminal({ ...settings, mode: mutation.mode }), - ...(mutation.syncNativeChat - ? { nativeChatPermissionMode: nativeMode, agentNativeChatPermissionModes: {} } - : {}) + if (mutation.exceptAuto && mutation.mode === 'auto') { + throw new Error('Auto cannot apply to agents without automatic permission review') } + return terminal({ + ...settings, + mode: mutation.mode, + ...(mutation.exceptAuto ? { exceptAuto: true } : {}) + }) } case 'command': { const commands = { ...settings.agentCmdOverrides } diff --git a/src/shared/agent-scoped-permission-defaults.test.ts b/src/shared/agent-scoped-permission-defaults.test.ts index 38ee3bd89f2..540c877125d 100644 --- a/src/shared/agent-scoped-permission-defaults.test.ts +++ b/src/shared/agent-scoped-permission-defaults.test.ts @@ -1,289 +1,43 @@ import { describe, expect, it } from 'vitest' import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings' -import { - newChatPermissionModeForSettings, - normalizeAgentNativeChatPermissionModes -} from './agent-chat-permission-mode' import { structuredLaunchSeedOptions } from './native-chat-session-option-defaults' +import { resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' import { AgentLaunchSettingsMutation } from './rpc-contract/agent-launch-settings-params' -import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' const settings = { - agentDefaultArgs: { ...YOLO_TUI_AGENT_ARGS }, - nativeChatPermissionMode: 'full-access' as const + agentPermissionDefault: 'full-access' as const, + nativeChatPermissionMode: 'ask' as const } -describe('scoped agent permission defaults', () => { - it.each(['darwin', 'linux', 'win32'] as const)( - 'changes only Codex terminal and future chats on %s', - (platform) => { - const updates = agentLaunchSettingsMutationUpdates( - settings, - { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, - platform - ) - const after = { ...settings, ...updates } - expect(after.agentDefaultArgs.codex).toBe('--approve-for-me') - expect(after.agentDefaultArgs.claude).toBe(YOLO_TUI_AGENT_ARGS.claude) - expect(after.agentDefaultArgs.gemini).toBe(YOLO_TUI_AGENT_ARGS.gemini) - expect(updates).not.toHaveProperty('nativeChatPermissionMode') - expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) - expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ - permissionMode: 'full-access' - }) - expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ permissionMode: 'full-access' }) - } - ) - - it('uses shipped launch defaults for an agent without a stored arguments key', () => { - const updates = agentLaunchSettingsMutationUpdates( - { nativeChatPermissionMode: 'ask' }, - { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, - 'linux' - ) - expect(updates.agentDefaultArgs).toEqual({ codex: '--approve-for-me' }) - expect(updates.agentNativeChatPermissionModes).toEqual({ codex: 'auto' }) +describe('one permission for terminals and new chats', () => { + it('uses a scoped legacy mutation for both surfaces without storing a chat copy', () => { + const mutation = AgentLaunchSettingsMutation.parse({ + type: 'permissions', + syncNativeChat: true, + agent: 'codex', + mode: 'auto' + }) + const updates = agentLaunchSettingsMutationUpdates(settings, mutation, 'linux') + const after = { ...settings, ...updates } + expect(resolveTuiAgentLaunchArgs('codex', after)).toBe('--approve-for-me') + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) + expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'full-access' }) + expect(updates).not.toHaveProperty('agentNativeChatPermissionModes') + expect(updates).not.toHaveProperty('nativeChatPermissionMode') }) - it('can configure Claude independently without adopting a model', () => { - const first = { + it('own sends no terminal flag and seeds delegation without changing saved model options', () => { + const after = { ...settings, ...agentLaunchSettingsMutationUpdates( settings, - { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, + { type: 'permission', agent: 'codex', choice: 'own' }, 'linux' ) } - const after = { - ...first, - ...agentLaunchSettingsMutationUpdates( - first, - { type: 'permissions', syncNativeChat: true, agent: 'claude', mode: 'manual' }, - 'linux' - ) - } - expect(after.agentNativeChatPermissionModes).toEqual({ codex: 'auto', claude: 'ask' }) - expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'ask' }) - expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) + expect(resolveTuiAgentLaunchArgs('codex', after)).toBe('') + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'own' }) + expect(structuredLaunchSeedOptions(after, 'pi')).toBeUndefined() expect(after).not.toHaveProperty('nativeChatSessionOptions') }) - - it.each(['yolo', 'manual'] as const)('bulk %s resets scoped chat overrides', (mode) => { - const before = { ...settings, agentNativeChatPermissionModes: { codex: 'auto' as const } } - const after = { - ...before, - ...agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', syncNativeChat: true, mode }, - 'linux' - ) - } - expect(after.agentNativeChatPermissionModes).toEqual({}) - expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ - permissionMode: mode === 'yolo' ? 'full-access' : 'ask' - }) - }) - - it.each(['manual', 'yolo'] as const)('changes only other agents with exceptAuto %s', (mode) => { - const before = { - ...settings, - agentNativeChatPermissionModes: { - claude: 'auto' as const, - codex: 'ask' as const, - grok: 'full-access' as const, - opencode: 'full-access' as const - } - } - const updates = agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', mode, exceptAuto: true, syncNativeChat: true }, - 'linux' - ) - const after = { ...before, ...updates } - expect(updates).not.toHaveProperty('nativeChatPermissionMode') - expect(after.agentDefaultArgs.codex).toBe(settings.agentDefaultArgs.codex) - expect(after.agentDefaultArgs.claude).toBe(settings.agentDefaultArgs.claude) - expect(after.agentDefaultArgs.grok).toBe( - mode === 'manual' ? '' : settings.agentDefaultArgs.grok - ) - expect(after.agentNativeChatPermissionModes).toEqual({ - claude: 'auto', - codex: 'ask', - grok: mode === 'manual' ? 'ask' : 'full-access', - antigravity: mode === 'manual' ? 'ask' : 'full-access', - opencode: 'full-access' - }) - expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'auto' }) - expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'ask' }) - expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ permissionMode: 'full-access' }) - }) - - it('keeps Auto active when Other agents switches from Yolo to Manual', () => { - const automatic = { - ...settings, - ...agentLaunchSettingsMutationUpdates( - settings, - { type: 'permissions', mode: 'auto', syncNativeChat: true }, - 'linux' - ) - } - const after = { - ...automatic, - ...agentLaunchSettingsMutationUpdates( - automatic, - { type: 'permissions', mode: 'manual', exceptAuto: true, syncNativeChat: true }, - 'linux' - ) - } - expect(after.agentDefaultArgs).toMatchObject({ - codex: '--approve-for-me', - claude: '--permission-mode auto', - grok: '' - }) - expect(after.nativeChatPermissionMode).toBe('full-access') - expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) - expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'auto' }) - expect(structuredLaunchSeedOptions(after, 'grok')).toEqual({ permissionMode: 'ask' }) - expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ permissionMode: 'full-access' }) - }) - - it('preserves custom Auto-capable terminal and native overrides under global Auto', () => { - const before = { - ...settings, - agentDefaultArgs: { ...settings.agentDefaultArgs, claude: '--model custom' }, - agentNativeChatPermissionModes: { claude: 'accept-edits' as const, grok: 'ask' as const } - } - const updates = agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', mode: 'auto', syncNativeChat: true }, - 'linux' - ) - expect(updates.agentDefaultArgs?.claude).toBe('--model custom') - expect(updates.agentNativeChatPermissionModes).toEqual({ - claude: 'accept-edits', - codex: 'auto', - grok: 'ask' - }) - expect(updates).not.toHaveProperty('nativeChatPermissionMode') - }) - - it('preserves custom other agents under exceptAuto and leaves legacy calls terminal-only', () => { - const before = { - ...settings, - agentDefaultArgs: { ...settings.agentDefaultArgs, grok: '--model custom' }, - agentNativeChatPermissionModes: { claude: 'auto' as const, grok: 'full-access' as const } - } - const updates = agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', mode: 'manual', exceptAuto: true, syncNativeChat: true }, - 'linux' - ) - expect(updates.agentDefaultArgs?.grok).toBe('--model custom') - expect(updates.agentNativeChatPermissionModes?.grok).toBe('full-access') - const legacy = agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', mode: 'manual', exceptAuto: true }, - 'linux' - ) - expect(legacy).not.toHaveProperty('nativeChatPermissionMode') - expect(legacy).not.toHaveProperty('agentNativeChatPermissionModes') - }) - - it('reapplies the same terminal choice to restore its scoped native default', () => { - const before = { - agentDefaultArgs: { codex: '--approve-for-me' }, - nativeChatPermissionMode: 'ask' as const - } - const updates = agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, - 'linux' - ) - expect(updates.agentDefaultArgs?.codex).toBe('--approve-for-me') - expect(structuredLaunchSeedOptions({ ...before, ...updates }, 'codex')).toEqual({ - permissionMode: 'auto' - }) - }) - - it('rejects custom arguments without losing options or changing native permissions', () => { - const before = { ...settings, agentDefaultArgs: { codex: '--model custom --approve-for-me' } } - expect(() => - agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'manual' }, - 'linux' - ) - ).toThrow('custom launch arguments') - expect(before.agentDefaultArgs.codex).toBe('--model custom --approve-for-me') - expect(before.nativeChatPermissionMode).toBe('full-access') - }) - - it('rejects scoped modes for agents without configurable terminal permissions', () => { - expect(() => - agentLaunchSettingsMutationUpdates( - settings, - { type: 'permissions', syncNativeChat: true, agent: 'pi', mode: 'yolo' }, - 'linux' - ) - ).toThrow('no configurable') - }) - - it('refuses Auto for an agent without a reviewer', () => { - expect(() => - agentLaunchSettingsMutationUpdates( - settings, - { type: 'permissions', syncNativeChat: true, agent: 'gemini', mode: 'auto' }, - 'linux' - ) - ).toThrow('does not support') - }) - - it('accepts both scoped and legacy permission wire mutations', () => { - expect( - AgentLaunchSettingsMutation.parse({ - type: 'permissions', - syncNativeChat: true, - agent: 'codex', - mode: 'auto' - }) - ).toEqual({ type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }) - expect(AgentLaunchSettingsMutation.parse({ type: 'permissions', mode: 'manual' })).toEqual({ - type: 'permissions', - mode: 'manual' - }) - }) - - it.each([undefined, 'codex'] as const)( - 'keeps parent-client permissions terminal-only (agent %s)', - (agent) => { - const before = { ...settings, agentNativeChatPermissionModes: { claude: 'ask' as const } } - const updates = agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', mode: 'auto', ...(agent ? { agent } : {}) }, - 'linux' - ) - expect(updates).not.toHaveProperty('nativeChatPermissionMode') - expect(updates).not.toHaveProperty('agentNativeChatPermissionModes') - expect(structuredLaunchSeedOptions({ ...before, ...updates }, 'codex')).toEqual({ - permissionMode: 'full-access' - }) - } - ) - - it('normalizes persisted overrides to only offered agent modes', () => { - expect( - normalizeAgentNativeChatPermissionModes({ - codex: 'auto', - claude: 'accept-edits', - omp: 'auto', - mystery: 'full-access' - }) - ).toEqual({ codex: 'auto', claude: 'accept-edits' }) - expect( - newChatPermissionModeForSettings('codex', { - nativeChatPermissionMode: 'full-access', - agentNativeChatPermissionModes: { codex: 'ask' } - }) - ).toBe('ask') - }) }) diff --git a/src/shared/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts index 0ef935e4a09..9658da0ef4d 100644 --- a/src/shared/agent-session-options-result.ts +++ b/src/shared/agent-session-options-result.ts @@ -35,6 +35,8 @@ export type AgentSessionOptionsResult = { serviceTier?: string /** The permission mode a start runs now; absent from a host or agent with no picker. */ permissionMode?: string + /** Agent report for own settings: null means reported but not a named Orca mode. */ + reportedPermissionMode?: string | null /** Provider-reported effective routing, distinct from the next-turn preference. */ fastModeState?: AgentSessionFastModeState /** Values actually reported by the provider, beyond acceptance alone. Older hosts omit this, preserving unconfirmed values. */ diff --git a/src/shared/agent-session-permission-modes-capability.ts b/src/shared/agent-session-permission-modes-capability.ts index 94bd652c1d8..41c97974b55 100644 --- a/src/shared/agent-session-permission-modes-capability.ts +++ b/src/shared/agent-session-permission-modes-capability.ts @@ -1,3 +1,7 @@ // Probe per-chat modes before start/send: older hosts ignore them and use Agent Permissions. Versions 1.4.225–1.4.226 apply unadvertised modes; recognize nativeChatPermissionMode in their settings. export const AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY = 'agent-session.permission-modes.v1' as const + +// Host accepts own chat intent and reports the permission the agent selected. +export const AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY = + 'agent-session.permission-own.v1' as const diff --git a/src/shared/agent-session-status-snapshot-fold.test.ts b/src/shared/agent-session-status-snapshot-fold.test.ts index 916728490f8..62a8e092991 100644 --- a/src/shared/agent-session-status-snapshot-fold.test.ts +++ b/src/shared/agent-session-status-snapshot-fold.test.ts @@ -23,7 +23,7 @@ it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed', 'skipped'] }) expect(snapshot.get('a')?.restartResume?.phase).toBe(phase) const disconnected = revokeAgentSessionStatusLive(snapshot) - expect(disconnected.get('a')).toEqual(summary) + expect(disconnected.get('a')).toEqual({ ...summary, runtimePermission: null }) expect(revokeAgentSessionStatusLive(disconnected)).toBe(disconnected) } ) diff --git a/src/shared/agent-session-status-snapshot-fold.ts b/src/shared/agent-session-status-snapshot-fold.ts index e5daad286fd..239a9a27cde 100644 --- a/src/shared/agent-session-status-snapshot-fold.ts +++ b/src/shared/agent-session-status-snapshot-fold.ts @@ -30,7 +30,12 @@ export function revokeAgentSessionStatusLive( ): AgentSessionStatusSnapshot { let next: Map | null = null for (const [sessionId, summary] of snapshot) { - if (!summary.hostExecutionOwned && !summary.stopping && !summary.restartResume) { + if ( + !summary.hostExecutionOwned && + !summary.stopping && + !summary.restartResume && + summary.runtimePermission === null + ) { continue } next ??= new Map(snapshot) @@ -41,7 +46,10 @@ export function revokeAgentSessionStatusLive( restartResume: _restartResume, ...retained } = summary - next.set(sessionId, retained) + next.set(sessionId, { + ...retained, + runtimePermission: null + }) } return next ?? snapshot } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 1a6c99bcaae..704dd5c46d7 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -223,6 +223,7 @@ export type AgentSessionStatusSummary = { model?: string /** The chat's saved permission mode; a client re-reads options when it or `model` moves. */ permissionMode?: string + runtimePermission?: { fence: number; mode: string | null } | null /** The tool the running turn is inside, else the last one it used. Absent unless `status` * is 'working'. */ toolName?: string diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 32d8420ccac..7966de41497 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -219,10 +219,8 @@ export type GlobalSettings = GlobalSettingsBase & { nativeChatShellEnvironmentVariables?: string[] /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ nativeChatSessionOptions?: PersistedNativeChatSessionOptions - /** New chats' permission mode, for agents whose chats offer modes; seeded once from Agent Permissions. */ + /** Legacy client permission control and host-support marker; kept valid for old peers. */ nativeChatPermissionMode?: AgentChatPermissionMode - /** Per-agent new-chat defaults chosen alongside that agent's terminal permission mode. */ - agentNativeChatPermissionModes?: Partial> /** 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 b8ffa4a8e4e..c29897ab19f 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -1,7 +1,7 @@ import type { AgentType } from './agent-status-types' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, - isAgentChatPermissionMode, + isAgentChatPermissionChoice, newChatPermissionModeForSettings, type NewChatPermissionSettings } from './agent-chat-permission-mode' @@ -96,7 +96,7 @@ export function parseStructuredLaunchSeedOptions( } } const permissionMode = entries.get(AGENT_CHAT_PERMISSION_MODE_OPTION_ID) - if (isAgentChatPermissionMode(permissionMode)) { + if (isAgentChatPermissionChoice(permissionMode)) { parsed[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] = permissionMode } return Object.keys(parsed).length > 0 ? parsed : undefined @@ -113,10 +113,7 @@ export function resolveStructuredLaunchSeedOptions( /** A new chat's seed: the saved selection plus the permission default narrowed to the agent. */ export function structuredLaunchSeedOptions( settings: - | (Pick< - GlobalSettings, - 'nativeChatSessionOptions' | 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes' - > & + | (Pick & NewChatPermissionSettings) | null | undefined, diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 4aba2b45d2e..4f350a85c9c 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -62,6 +62,8 @@ export type StructuredAgentSessionOptionState = { defaultsUnsent?: true /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null + reportedPermissionMode?: string | null + permissionOwnSupported?: boolean | null pendingId: string | null listingInProgress?: boolean fastModeRestricted?: boolean @@ -196,7 +198,8 @@ export function applyStructuredAgentSessionOptions( : result.listedModelIds?.length ? result.listedModelIds : undefined, - permissionMode: result.current.permissionMode ?? null + permissionMode: result.current.permissionMode ?? null, + reportedPermissionMode: result.current.reportedPermissionMode } } @@ -216,7 +219,7 @@ export function commitStructuredAgentSessionOption( value: string ): StructuredAgentSessionOptionState { if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { - return { ...state, permissionMode: value, pendingId: null } + return { ...state, permissionMode: value, reportedPermissionMode: undefined, pendingId: null } } if (!state.catalog) { return state diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index c55d57a63f7..be9bd4f7de5 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -39,6 +39,27 @@ function live(agent: string, current: AgentSessionOptionsResult['current'], auto } describe('the permission mode picker', () => { + it('keeps old-host fallback while capable hosts display only the nullable report', () => { + const pr1 = live('codex', { permissionMode: 'ask' }) + expect(permission({ ...pr1, permissionOwnSupported: null })?.settable).toBe(false) + expect(permission({ ...pr1, permissionOwnSupported: false })).toMatchObject({ + settable: true, + kind: { currentValue: 'ask' } + }) + const capable = { ...pr1, permissionOwnSupported: true, permissionMode: 'own' } + for (const reportedPermissionMode of [undefined, null, 'full-access', 'read-only']) { + const descriptor = permission({ ...capable, reportedPermissionMode }) + expect(descriptor).toMatchObject({ + settable: typeof reportedPermissionMode === 'string', + kind: { + currentValue: reportedPermissionMode ?? undefined, + choices: expect.not.arrayContaining([expect.objectContaining({ value: 'own' })]) + } + }) + } + expect(permission({ ...capable, permissionMode: 'ask' })?.settable).toBe(false) + }) + it('shows the seeded mode before the agent starts, beside the quiet model pill', () => { const state = createStructuredAgentSessionOptionState( 'codex', diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 86eac3f0bd5..a191d8fae8d 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -70,18 +70,34 @@ export function structuredAgentSessionPermissionDescriptor(state: { record: StructuredAgentSessionOptionRecord permissionMode: string | null pendingId?: string | null + permissionOwnSupported?: boolean | null + reportedPermissionMode?: string | null }): SessionOptionDescriptor | null { const support = agentChatPermissionModes(state.record.agent) - const current = support?.modes.find((mode) => mode === state.permissionMode) - if (!support || !current) { + const own = state.permissionMode === 'own' + if (!support || (!own && !support.modes.some((mode) => mode === state.permissionMode))) { return null } + const current = + state.reportedPermissionMode === undefined && !own && state.permissionOwnSupported !== true + ? (state.permissionMode ?? undefined) + : (state.reportedPermissionMode ?? undefined) const offered = offeredChatPermissionModes( support, - current === 'auto' || + state.permissionMode === 'auto' || + current === 'auto' || (state.pendingId !== 'model' && currentModelSupportsAutoMode(state.catalog, state.record)) ) + const reported = support.modes.find((mode) => mode === current) + const label = reported + ? MODE_COPY[reported].label + : current === 'plan' + ? 'Plan mode' + : current === 'read-only' + ? 'Read only' + : (current ?? 'Permissions') return { + description: label, id: AGENT_CHAT_PERMISSION_MODE_OPTION_ID, label: 'Permissions', category: 'mode', @@ -94,9 +110,14 @@ export function structuredAgentSessionPermissionDescriptor(state: { description: agentDescriptions(state.record.agent)[value] ?? MODE_COPY[value].description })) }, - valueSource: 'applied', + valueSource: + current === undefined + ? 'unknown' + : state.reportedPermissionMode === undefined + ? 'applied' + : 'reported', transport: 'agent-session', - settable: true + settable: state.permissionOwnSupported !== null && current !== undefined } } @@ -126,3 +147,26 @@ export function legacyHostPermissionDescriptor(): SessionOptionDescriptor { settable: false } } + +/** Absent producers retain options fallback; present evidence owns the description, even cleared. */ +export function projectRuntimePermission( + state: T, + summary: unknown, + fence: number | null +): T { + if (typeof summary !== 'object' || summary === null || !('runtimePermission' in summary)) { + return state + } + const report = summary.runtimePermission + const valid = + typeof report === 'object' && + report !== null && + 'fence' in report && + Number.isSafeInteger(report.fence) && + typeof report.fence === 'number' && + report.fence >= 0 && + report.fence === fence && + 'mode' in report && + (report.mode === null || typeof report.mode === 'string') + return { ...state, reportedPermissionMode: valid ? report.mode : undefined } +} diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index 1013b29225d..831f01103f4 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from './agent-session-permission-modes-capability' // Registered-agent hosts accept only their agentSession.agents inventory; offer agents beyond Claude/Codex only after negotiation. // Clients advertising this can render those agents’ tabs; withhold extra-agent tabs from older clients to avoid empty panes. export const STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY = @@ -33,6 +34,7 @@ export const AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY = 'agent-session.coherent-model-catalog.v1' as const export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY, AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, diff --git a/src/shared/tui-agent-permission-choice.test.ts b/src/shared/tui-agent-permission-choice.test.ts index 37b6b778ffe..7f2d6ad7347 100644 --- a/src/shared/tui-agent-permission-choice.test.ts +++ b/src/shared/tui-agent-permission-choice.test.ts @@ -176,10 +176,10 @@ describe('older clients against a host with the stored choice', () => { expect(resolveTuiAgentLaunchArgs('claude', { ...host, ...manual })).toBe('') }) - it('new chats start in the stored choice; agents on their own settings keep the Chat default', () => { + it('new chats start in the stored choice; agents on their own settings delegate their chats too', () => { expect(newChatPermissionModeForSettings('codex', host)).toBe('auto') const own = { ...host, agentPermissionOverrides: { codex: 'own' as const } } - expect(newChatPermissionModeForSettings('codex', own)).toBe('ask') + expect(newChatPermissionModeForSettings('codex', own)).toBe('own') }) }) @@ -600,14 +600,14 @@ describe('permission carrier ownership across save, reload and consumers', () => }) }) - it('preserves chat-only migration defaults, then seeds Ask and retains raw choices through Auto', () => { + it('migrates agents without terminal flags to own, then retains explicit chat choices through Auto', () => { const agents = ['antigravity', 'opencode', 'opencode2', 'omp'] as const const upgraded = { ...migrateAgentPermissionChoice(legacy({})), nativeChatPermissionMode: 'ask' as const } for (const agent of ['opencode', 'opencode2', 'omp'] as const) { - expect(newChatPermissionModeForSettings(agent, upgraded)).toBe('ask') + expect(newChatPermissionModeForSettings(agent, upgraded)).toBe('own') } const ask = { ...upgraded, ...applyAllAgentsPermission(upgraded, 'ask') } for (const agent of agents) { diff --git a/tests/e2e/__mocks__/mobile-structured-options.ts b/tests/e2e/__mocks__/mobile-structured-options.ts new file mode 100644 index 00000000000..3f2f7f6b980 --- /dev/null +++ b/tests/e2e/__mocks__/mobile-structured-options.ts @@ -0,0 +1,57 @@ +import { build } from 'esbuild' +import { mkdtemp, writeFile, unlink, rmdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { createRequire } from 'node:module' +import type { useMobileStructuredAgentOptions } from '../../../mobile/src/session/use-mobile-structured-agent-options' + +/** Execute the real phone hook in root-only workers, which do not install Expo. */ +export async function loadMobileStructuredOptions(): Promise< + typeof useMobileStructuredAgentOptions +> { + const result = await build({ + entryPoints: [resolve('mobile/src/session/use-mobile-structured-agent-options.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + tsconfigRaw: {}, + plugins: [ + { + name: 'root-unit-phone-platform', + setup(builder) { + builder.onResolve({ filter: /^react$/ }, () => ({ + path: resolve('node_modules/react/index.js'), + external: true + })) + builder.onResolve({ filter: /\/random-v4-uuid$/ }, () => ({ + path: 'uuid', + namespace: 'unit-phone' + })) + builder.onLoad({ filter: /^uuid$/, namespace: 'unit-phone' }, () => ({ + resolveDir: process.cwd(), + contents: `export { createBrowserUuid as randomV4Uuid } from ${JSON.stringify(resolve('src/renderer/src/lib/browser-uuid.ts'))}` + })) + } + } + ] + }) + const source = result.outputFiles[0]?.text + if (!source) { + throw new Error('Phone hook source was not compiled') + } + const dir = await mkdtemp(join(tmpdir(), 'orca-pr32334-phone-options-')) + const file = join(dir, 'phone-options.cjs') + try { + await writeFile(file, source) + const loaded: { useMobileStructuredAgentOptions: typeof useMobileStructuredAgentOptions } = + createRequire(resolve('package.json'))(file) + if (typeof loaded.useMobileStructuredAgentOptions !== 'function') { + throw new Error('Phone hook export is unavailable') + } + return loaded.useMobileStructuredAgentOptions + } finally { + await unlink(file) + await rmdir(dir) + } +} diff --git a/tests/e2e/agent-auto-permissions.spec.ts b/tests/e2e/agent-auto-permissions.spec.ts index 92641f63cb7..6c56e32ec6f 100644 --- a/tests/e2e/agent-auto-permissions.spec.ts +++ b/tests/e2e/agent-auto-permissions.spec.ts @@ -3,94 +3,83 @@ import { waitForSessionReady } from './helpers/store' test.use({ seedTestRepo: false }) -test('global Auto syncs native defaults only for supporting agents and persists', async ({ +test('global Auto preserves other agents’ choices and the single permission setting persists', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) - await orcaPage.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('Orca store is unavailable') - } - return store.getState().updateSettingsOrThrow({ nativeChatPermissionMode: 'accept-edits' }) - }) await orcaPage.getByRole('button', { name: 'Settings', exact: true }).click() await orcaPage.getByRole('button', { name: 'Agents', exact: true }).click() - const group = orcaPage.getByRole('radiogroup', { name: 'Agent Permissions' }) + const permissions = orcaPage.getByRole('button', { name: 'Permissions', exact: true }) + const group = orcaPage.getByRole('group', { name: 'Permissions', exact: true }) const others = orcaPage.getByRole('radiogroup', { name: 'Permissions for other agents' }) - const readArgs = () => + const readPermissions = () => orcaPage.evaluate(async () => { const settings = await window.api.settings.get() return { - claude: settings.agentDefaultArgs?.claude, - codex: settings.agentDefaultArgs?.codex, - gemini: settings.agentDefaultArgs?.gemini, - native: settings.nativeChatPermissionMode, - claudeNative: settings.agentNativeChatPermissionModes?.claude, - codexNative: settings.agentNativeChatPermissionModes?.codex, - grokNative: settings.agentNativeChatPermissionModes?.grok + default: settings.agentPermissionDefault, + claude: settings.agentPermissionOverrides?.claude ?? settings.agentPermissionDefault, + codex: settings.agentPermissionOverrides?.codex ?? settings.agentPermissionDefault, + grok: settings.agentPermissionOverrides?.grok ?? settings.agentPermissionDefault } }) - await group.getByRole('radio', { name: 'Yolo', exact: true }).click() + await permissions.click() + await group.getByRole('menuitemradio', { name: /^Full access\b/ }).click() await expect(others).toHaveCount(0) - await group.getByRole('radio', { name: 'Auto', exact: true }).click() - await expect(group.getByRole('radio', { name: 'Auto', exact: true })).toHaveAttribute( + await permissions.click() + await group.getByRole('menuitemradio', { name: /^Approve for me\b/ }).click() + await permissions.click() + await expect(group.getByRole('menuitemradio', { name: /^Approve for me\b/ })).toHaveAttribute( 'aria-checked', 'true' ) - await expect(others.getByRole('radio', { name: 'Yolo', exact: true })).toHaveAttribute( + await group.getByRole('menuitemradio', { name: /^Approve for me\b/ }).press('Escape') + await expect(others.getByRole('radio', { name: 'Full access', exact: true })).toHaveAttribute( 'aria-checked', 'true' ) - await expect.poll(readArgs).toEqual({ - claude: '--permission-mode auto', - codex: '--approve-for-me', - gemini: '--yolo', - native: 'full-access', - claudeNative: 'auto', - codexNative: 'auto', - grokNative: undefined + await expect.poll(readPermissions).toEqual({ + default: 'auto', + claude: 'auto', + codex: 'auto', + grok: 'full-access' }) - await others.getByRole('radio', { name: 'Manual', exact: true }).click() - await expect.poll(readArgs).toEqual({ - claude: '--permission-mode auto', - codex: '--approve-for-me', - gemini: '', - native: 'full-access', - claudeNative: 'auto', - codexNative: 'auto', - grokNative: 'ask' + await others.getByRole('radio', { name: 'Own settings', exact: true }).click() + await expect.poll(readPermissions).toEqual({ + default: 'auto', + claude: 'auto', + codex: 'auto', + grok: 'own' }) await orcaPage.reload() await orcaPage.waitForFunction(() => Boolean(window.__store)) await waitForSessionReady(orcaPage) await orcaPage.getByRole('button', { name: 'Agents', exact: true }).click() - await expect(group.getByRole('radio', { name: 'Auto', exact: true })).toHaveAttribute( + await permissions.click() + await expect(group.getByRole('menuitemradio', { name: /^Approve for me\b/ })).toHaveAttribute( 'aria-checked', 'true' ) - await expect(others.getByRole('radio', { name: 'Manual', exact: true })).toHaveAttribute( + await group.getByRole('menuitemradio', { name: /^Approve for me\b/ }).press('Escape') + await expect(others.getByRole('radio', { name: 'Own settings', exact: true })).toHaveAttribute( 'aria-checked', 'true' ) - await expect.poll(readArgs).toMatchObject({ - native: 'full-access', - claudeNative: 'auto', - codexNative: 'auto', - grokNative: 'ask' + await expect.poll(readPermissions).toEqual({ + default: 'auto', + claude: 'auto', + codex: 'auto', + grok: 'own' }) - await group.getByRole('radio', { name: 'Manual', exact: true }).click() - await expect.poll(readArgs).toEqual({ - claude: '', - codex: '', - gemini: '', - native: 'ask', - claudeNative: undefined, - codexNative: undefined, - grokNative: undefined + await permissions.click() + await group.getByRole('menuitemradio', { name: /^Each agent's own settings\b/ }).click() + await expect.poll(readPermissions).toEqual({ + default: 'own', + claude: 'own', + codex: 'own', + grok: 'own' }) }) diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 365b2be067e..94cd2541e5e 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ rpc: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), callStructuredAgentSession: mocks.rpc })) vi.mock( diff --git a/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts index c150596fe20..f520efd7965 100644 --- a/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts +++ b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts @@ -27,6 +27,7 @@ vi.mock('../../src/main/agent-hooks/managed-agent-hook-controls', () => ({ vi.mock('../../src/renderer/src/components/settings/agent-launch-settings-transport', () => ({ readHostAgentLaunchSettings: transport.read, mutateHostAgentLaunchSettings: transport.mutate, + hostSupportsOwnChatPermissions: async () => true, hostSupportsAutoPermissions: async () => true, hostSupportsNativeChatPermissions: async () => true })) diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/tests/e2e/structured-agent-session-launch-admission.unit.test.ts similarity index 77% rename from src/renderer/src/lib/structured-agent-session-launch-admission.test.ts rename to tests/e2e/structured-agent-session-launch-admission.unit.test.ts index 8ac3c963356..d2113347d4e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/tests/e2e/structured-agent-session-launch-admission.unit.test.ts @@ -1,8 +1,16 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import type { Worktree } from '../../../shared/worktree/types' +import { act, renderHook } from '@testing-library/react' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultPersistedState } from '../../src/shared/constants' +import type { PersistedState } from '../../src/shared/persisted-state-types' +import { normalizeLoadedGlobalSettings } from '../../src/main/persistence/loading-store/normalize-loaded-global-settings' +import { prepareLoadedTerminalSettings } from '../../src/main/persistence/loading-store/prepare-loaded-terminal-settings' +import { prepareLoadedProfileSettings } from '../../src/main/persistence/loading-store/prepare-loaded-profile-settings' +import { useNativeChatHostPermissionGate } from '@/components/native-chat/use-native-chat-host-permission-gate' +import * as runtimeRpc from '@/runtime/runtime-rpc-client' +import type * as HostPermission from '@/runtime/structured-chat-host-permission' +import type { Worktree } from '../../src/shared/worktree/types' const mocks = vi.hoisted(() => ({ createSupport: vi.fn(), @@ -26,7 +34,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ }) })) -vi.mock('@/runtime/structured-chat-host-permission', () => ({ +vi.mock('@/runtime/structured-chat-host-permission', async (importOriginal) => ({ + ...(await importOriginal()), readStructuredChatHostPermissionVerdict: mocks.hostPermission })) // Switch-on, main opens this machine's chats and its frames draw them. @@ -42,14 +51,14 @@ vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => ) import { useAppStore } from '@/store' -import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' -import { beginStructuredAgentSessionProvisionalLaunch } from './agent-session/structured-agent-session-provisional-tab' -import { beginDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing' -import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-status' -import { getStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options' +import { adoptAgentSessionLaunchVerdict } from '../../src/renderer/src/lib/agent-session-launch-plan' +import { beginStructuredAgentSessionProvisionalLaunch } from '../../src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab' +import { beginDirectWorkItemStructuredLaunch } from '../../src/renderer/src/lib/launch-work-item-direct-agent-routing' +import { getStructuredAgentLaunchStatus } from '../../src/renderer/src/lib/structured-agent-session-launch-status' +import { getStructuredAgentSessionLaunchSelection } from '../../src/renderer/src/lib/structured-agent-session-launch-options' import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent' -import { LOCAL_ADMISSION_WAIT_MS } from './structured-agent-session-host-admission' -import { launchStructuredAgentFromNewTab } from './launch-agent-in-new-tab-structured-route' +import { LOCAL_ADMISSION_WAIT_MS } from '../../src/renderer/src/lib/structured-agent-session-host-admission' +import { launchStructuredAgentFromNewTab } from '../../src/renderer/src/lib/launch-agent-in-new-tab-structured-route' import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' import { drawTabsFromMain, @@ -129,7 +138,10 @@ beforeEach(async () => { main = await drawTabsFromMain(useAppStore) }) -afterEach(() => main.close()) +afterEach(() => { + main.close() + vi.restoreAllMocks() +}) describe('a structured chat launch on a paired server', () => { beforeEach(seedPairedServerWorkspace) @@ -338,6 +350,101 @@ describe('a structured chat launch on a paired server', () => { } ) + it.each(['own', 'ask'] as const)( + 'loads and reloads saved %s through old-host launch and mounted Send admission', + async (choice) => { + const defaults = getDefaultPersistedState('/fixture/c3-old-host') + let parsed: PersistedState = { + ...defaults, + settings: { + ...defaults.settings, + agentPermissionDefault: choice, + agentPermissionOverrides: {}, + agentYoloDefaultsMigrated: true, + agentDefaultArgs: { claude: '', codex: '' }, + nativeChatPermissionMode: 'full-access' + } + } + for (let load = 0; load < 2; load++) { + const noop = (): void => {} + const settings = normalizeLoadedGlobalSettings( + parsed, + prepareLoadedTerminalSettings(parsed, noop), + prepareLoadedProfileSettings(parsed, defaults, noop) + ) + expect(settings.agentPermissionDefault).toBe(choice) + expect(settings.nativeChatPermissionMode).toBe('ask') + useAppStore.setState({ settings }) + parsed = { ...parsed, settings: JSON.parse(JSON.stringify(settings)) } + } + const actual = await vi.importActual( + '@/runtime/structured-chat-host-permission' + ) + mocks.hostPermission.mockImplementation(actual.readStructuredChatHostPermissionVerdict) + let answerCapability: (supported: boolean) => void = () => {} + const capability = new Promise((resolve) => { + answerCapability = resolve + }) + const capabilityRead = vi + .spyOn(runtimeRpc, 'runtimeEnvironmentSupportsCapability') + .mockReturnValue(capability) + let answerSettings: (value: { + settings: { agentDefaultArgs: Record } + }) => void = () => {} + const hostSettings = new Promise<{ settings: { agentDefaultArgs: Record } }>( + (resolve) => { + answerSettings = resolve + } + ) + const settingsRead = vi.spyOn(runtimeRpc, 'callRuntimeRpc').mockReturnValue(hostSettings) + mocks.createSupport.mockResolvedValue({ supported: true }) + const mounted = renderHook(() => + useNativeChatHostPermissionGate( + { kind: 'environment', environmentId: 'server-1' }, + 'claude', + 'Claude' + ) + ) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: pairedPlan(), + hooks: {}, + onHostDeclined: vi.fn() + }) + await vi.waitFor(() => expect(capabilityRead).toHaveBeenCalledTimes(2)) + expect(settingsRead).not.toHaveBeenCalled() + expect(mounted.result.current.sendBlockedReason).not.toBeNull() + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) + await act(async () => answerCapability(false)) + await vi.waitFor(() => expect(settingsRead).toHaveBeenCalledTimes(2)) + for (const call of settingsRead.mock.calls) { + expect(call).toEqual([ + { kind: 'environment', environmentId: 'server-1' }, + 'settings.get', + undefined, + { timeoutMs: 15_000 } + ]) + } + expect(mounted.result.current.sendBlockedReason).not.toBeNull() + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) + await act(async () => answerSettings({ settings: { agentDefaultArgs: {} } })) + if (choice === 'own') { + await vi.waitFor(() => expect(mounted.result.current.sendBlockedReason).toBeNull()) + await vi.waitFor(() => + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1) + ) + expect(mocks.toastError).not.toHaveBeenCalled() + } else { + await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed', notified: true }) + await vi.waitFor(() => + expect(mounted.result.current.notice?.key).toBe('host-permission:refused') + ) + expectNoChatCommitted() + } + mounted.unmount() + launch?.cancel() + } + ) + it('opens the chat on the server that admitted it', async () => { mocks.createSupport.mockResolvedValue({ supported: true }) diff --git a/tests/e2e/structured-permission-report-publication.unit.test.ts b/tests/e2e/structured-permission-report-publication.unit.test.ts new file mode 100644 index 00000000000..ef87bfd95c0 --- /dev/null +++ b/tests/e2e/structured-permission-report-publication.unit.test.ts @@ -0,0 +1,453 @@ +// @vitest-environment happy-dom +import { act, renderHook, waitFor, cleanup } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { SDKStatusMessage } from '@anthropic-ai/claude-agent-sdk' +const transport = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: transport.call, + subscribeStructuredAgentSessionStatus: transport.subscribe +})) +import { useStructuredAgentSessionOptions } from '../../src/renderer/src/components/native-chat/use-structured-agent-session-options' +import { loadMobileStructuredOptions } from './__mocks__/mobile-structured-options' +const useMobileStructuredAgentOptions = await loadMobileStructuredOptions() +import type { RpcClient } from '../../mobile/src/transport/rpc-client' +import { + adapterAtPublishFor, + fakeClaude, + claudeFrame, + USER_MESSAGE, + PROVIDER_SESSION_ID +} from '../../src/main/claude/claude-structured-session-test-support' +import type { ClaudeStructuredSessionEvent } from '../../src/main/claude/claude-structured-session-state' +import { structuredClaudeLifecycleEvent } from '../../src/main/runtime/structured-claude-runtime-adapter' +import { + hostTestState, + attachParams, + envelope, + CALLER +} from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as sessionId } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../src/shared/agent-session-permission-modes-capability' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { acpLaunchSpecFor } from '../../src/main/acp/acp-launch-specs' +import { openHostRig, launch } from '../../src/main/acp/acp-structured-host.test-support' +import { GROK_CONFIG_OPTIONS } from '../../src/main/acp/acp-structured-adapter.test-support' +import { closeProviderTimelineRigs } from '../../src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host' + +afterEach(closeProviderTimelineRigs) + +function permissionConsumersFor( + host: StructuredAgentSessionHost, + agent: string, + fence: number, + count = 1 +) { + let id = 0 + const subscriptions: (() => void)[] = [] + const subscribe = (emit: Parameters[0]['emit']) => { + const key = `view-${id++}` + const dispose = host.subscribeStatus({ id: key, emit }) + subscriptions.push(dispose) + return dispose + } + transport.subscribe.mockImplementation(async (_target, emit) => ({ + unsubscribe: subscribe(emit) + })) + const read = vi.fn(async (method: string) => { + if (method !== 'agentSession.options') { + return new Promise(() => {}) + } + return host.readOptions(sessionId) + }) + transport.call.mockImplementation((_target, method) => read(method)) + const client: RpcClient = { + getState: () => 'connected', + onStateChange: () => () => {}, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + updateTerminalSubscriptionViewport: () => {}, + notifyForeground: () => {}, + close: () => {}, + subscribe: (_method, _params, emit) => subscribe(emit), + sendRequest: async (method) => ({ id: 'report-rpc', ok: true, result: await read(method) }) + } + const write = vi.fn() + write.mockImplementation( + async (_method, _fingerprint, fields: { key: string; value: string }) => { + const result = await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + if (!result.ok) { + throw new Error(`Permission pick refused: ${result.refusal.code}`) + } + return result.value + } + ) + const phoneWrite = vi.fn() + phoneWrite.mockImplementation(async (...args) => ({ + status: 'accepted', + value: await write(...args), + sameFence: true + })) + const target = { kind: 'local' } as const + const common = { agent, sessionId, fence } + const hooks = [ + () => + useStructuredAgentSessionOptions({ + ...common, + target, + transportEnabled: true, + isVisible: true, + providerVisible: true, + providerStarting: false, + turnId: null, + unloadedTurnRevisions: undefined, + hostOptionStamp: 'model|own', + mutate: write + }), + () => + useMobileStructuredAgentOptions({ + ...common, + client, + enabled: true, + optionStamp: 'model|own', + hostSupport: { + permissionModes: true, + permissionOwn: true, + statusFeed: true, + coherentModelCatalog: false + }, + mutate: phoneWrite + }) + ] + const mount = () => + hooks.flatMap((useOptions) => + Array.from({ length: count }, () => + renderHook(() => { + const { optionSnapshot, setStructuredOption } = useOptions() + return { optionSnapshot, setStructuredOption } + }) + ) + ) + return { mount, read, write, subscriptions } +} + +it.each([1, 10, 100])( + 'publishes idle permission reports to %s desktop and phone consumers without readback/activity', + async (count) => { + const { host, store } = hostTestState() + const events: ClaudeStructuredSessionEvent[] = [] + const claude = fakeClaude(), + adapter = adapterAtPublishFor(claude, {}, events) + host.deps.adapter = adapter + const sink = { publish: vi.fn(), forget: vi.fn() }, + onStatusChanged = vi.fn() + host.deps.statusSink = sink + host.deps.onSessionStatusChanged = onStatusChanged + try { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY]) + const settlements: Promise[] = [] + vi.spyOn(events, 'push').mockImplementation((...incoming) => { + incoming.forEach((event) => { + const lifecycle = structuredClaudeLifecycleEvent(event) + if (lifecycle) { + settlements.push(host.handleAdapterEvent(lifecycle)) + } + }) + return Array.prototype.push.apply(events, incoming) + }) + expect( + ( + await host.attach( + CALLER, + attachParams({ + provider: 'claude', + agent: 'claude', + providerHandle: undefined, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' }, + options: { permissionMode: 'own' } + }) + ) + ).ok + ).toBe(true) + await waitFor(() => + expect(host.readStatusSummary(sessionId)?.hostExecutionPhase).toBe('ready') + ) + const fence = store.getRecord(sessionId)!.lease.runtimeFence + const connection = claude.connections[0]! + await adapter.dispatch({ + sessionId, + clientMessageId: 'report-turn', + body: USER_MESSAGE, + fence + }) + claudeFrame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init', + permissionMode: 'acceptEdits' + }) + claudeFrame(connection, { + type: 'result', + subtype: 'success', + uuid: 'result', + is_error: false, + result: 'done', + stop_reason: 'end_turn', + terminal_reason: 'completed' + }) + claudeFrame(connection, { + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle' + }) + await Promise.all(settlements) + await host.flushStreamedEvents(sessionId) + expect(adapter['sessions'].get(sessionId)?.translator?.currentTurnId).toBeNull() + const { mount, read, write, subscriptions } = permissionConsumersFor( + host, + 'claude', + fence, + count + ) + let views = mount() + const descriptions = () => + views.map( + (view) => + view.result.current.optionSnapshot.find((row) => row.id === 'permissionMode') + ?.description + ) + await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Accept edits'))) + sink.publish.mockClear() + onStatusChanged.mockClear() + const unchanged = host.readStatusSummary(sessionId)! + const saved = structuredClone(store.getRecord(sessionId)) + const publications: unknown[] = [] + subscriptions.push( + host.subscribeStatus({ + id: 'publication-control', + emit: (event) => { + if (event.type === 'status') { + publications.push(event) + } + } + }) + ) + const reads = read.mock.calls.filter(([method]) => method === 'agentSession.options').length + const frame: SDKStatusMessage = { + type: 'system', + subtype: 'status', + status: null, + permissionMode: 'default', + session_id: PROVIDER_SESSION_ID, + uuid: 'a1111111-1111-4111-8111-111111111111' + } + await act(async () => { + claudeFrame(connection, frame) + await Promise.all(settlements) + }) + await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Ask for approval'))) + expect(read.mock.calls.filter(([method]) => method === 'agentSession.options')).toHaveLength( + reads + ) + expect(publications).toHaveLength(1) + expect({ + ...host.readStatusSummary(sessionId), + runtimePermission: unchanged.runtimePermission + }).toEqual(unchanged) + expect(store.getRecord(sessionId)).toEqual(saved) + await act(async () => { + claudeFrame(connection, { ...frame, uuid: 'b1111111-1111-4111-8111-111111111111' }) + await Promise.all(settlements) + }) + expect(publications).toHaveLength(1) + expect(sink.publish).not.toHaveBeenCalled() + expect(onStatusChanged).not.toHaveBeenCalled() + expect(store.getRecord(sessionId)?.permissionMode).toBe('own') + const desktop = views[0], + phone = views[count] + if (!desktop || !phone) { + throw new Error('Both mounted permission consumers are required') + } + await act(async () => { + expect(await desktop.result.current.setStructuredOption('permissionMode', 'ask')).toBe(true) + }) + expect(store.getRecord(sessionId)?.permissionMode).toBe('ask') + expect(host.readStatusSummary(sessionId)?.runtimePermission).toEqual({ + fence, + mode: 'ask' + }) + expect((await host.readOptions(sessionId)).current).toMatchObject({ + permissionMode: 'ask', + reportedPermissionMode: 'ask' + }) + await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Ask for approval'))) + expect( + views.every( + (view) => + view.result.current.optionSnapshot.find((row) => row.id === 'permissionMode')?.settable + ) + ).toBe(true) + await act(async () => { + expect( + await phone.result.current.setStructuredOption('permissionMode', 'full-access') + ).toBe(true) + }) + expect(write.mock.calls.map(([, , fields]) => fields)).toEqual([ + { key: 'permissionMode', value: 'ask' }, + { key: 'permissionMode', value: 'full-access' } + ]) + expect(store.getRecord(sessionId)?.permissionMode).toBe('full-access') + expect(host.readStatusSummary(sessionId)?.runtimePermission).toEqual({ + fence, + mode: 'ask' + }) + views.forEach((view) => view.unmount()) + views = mount() + await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Ask for approval'))) + expect((await host.readOptions(sessionId)).current).toMatchObject({ + permissionMode: 'full-access', + reportedPermissionMode: 'ask' + }) + await act(async () => { + claudeFrame(connection, { + ...frame, + permissionMode: 'bypassPermissions', + uuid: 'c1111111-1111-4111-8111-111111111111' + }) + await Promise.all(settlements) + }) + await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Full access'))) + expect( + views.every( + (view) => + view.result.current.optionSnapshot.find((row) => row.id === 'permissionMode')?.settable + ) + ).toBe(true) + expect((await host.readOptions(sessionId)).current).toMatchObject({ + permissionMode: 'full-access', + reportedPermissionMode: 'full-access' + }) + expect(store.getRecord(sessionId)?.lease.runtimeFence).toBe(fence) + await act(async () => { + subscriptions.forEach((dispose) => dispose()) + }) + expect(descriptions()).toEqual(Array(count * 2).fill('Permissions')) + } finally { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + resetHostModelCatalogSnapshotsForTests() + await adapter.closeAll() + setLocalRuntimeCapabilitiesForTests(null) + } + } +) + +it.each([ + ...(['omp', 'grok', 'opencode', 'opencode2'] as const).flatMap((agent) => + ( + [ + { fullAccess: false, savedMode: 'full-access' }, + { fullAccess: true, savedMode: 'ask' } + ] as const + ).map((policy) => ({ agent, ...policy })) + ), + { agent: 'omp' as const, fullAccess: false, savedMode: 'own' as const } +])( + 'keeps $agent saved $savedMode controls tied to installed fullAccess=$fullAccess, not saved intent', + async ({ agent, fullAccess, savedMode }) => { + const spec = acpLaunchSpecFor(agent) + if (!spec) { + throw new Error('Production ACP spec is required') + } + expect(spec.permissionModeConfig).toBeUndefined() + const resolved = launch(() => false, spec) + const { host, store, rig, ready, fence } = await openHostRig({ + spec, + deps: { resolveLaunch: async () => ({ ...(await resolved()), fullAccess }) } + }) + const mode = fullAccess ? 'full-access' : 'ask' + const other = fullAccess ? 'ask' : 'full-access' + const description = fullAccess ? 'Full access' : 'Ask for approval' + try { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY]) + expect( + await host.attach( + CALLER, + attachParams({ + provider: agent, + agent, + providerHandle: undefined, + accountHome: + 'accountHomeVariable' in spec.account.pin + ? { variable: spec.account.pin.accountHomeVariable, path: '/accounts/acp' } + : { kind: 'opencode', locator: { kind: 'unmanaged' } }, + options: { permissionMode: savedMode } + }) + ) + ).toMatchObject({ ok: true }) + await ready() + const currentFence = fence() + const { mount, write } = permissionConsumersFor(host, agent, currentFence) + let views = mount() + const rows = () => + views.map((view) => + view.result.current.optionSnapshot.find((row) => row.id === 'permissionMode') + ) + await waitFor(() => + expect(rows().map((row) => row?.description)).toEqual([description, description]) + ) + expect(store.getRecord(sessionId)?.permissionMode).toBe(savedMode) + expect((await host.readOptions(sessionId)).current).toMatchObject({ + permissionMode: savedMode, + reportedPermissionMode: mode + }) + expect(host.readStatusSummary(sessionId)?.runtimePermission).toEqual({ + fence: currentFence, + mode + }) + const desktop = views[0], + phone = views[1] + if (!desktop || !phone) { + throw new Error('Both mounted consumers are required') + } + await act(async () => { + expect(await desktop.result.current.setStructuredOption('permissionMode', mode)).toBe(true) + }) + await act(async () => { + expect(await phone.result.current.setStructuredOption('permissionMode', other)).toBe(true) + }) + expect(write).toHaveBeenCalledTimes(2) + expect(store.getRecord(sessionId)?.permissionMode).toBe(other) + rig.child().agent.notify('session/update', { + sessionId: 'acp-session-1', + update: { sessionUpdate: 'config_option_update', configOptions: GROK_CONFIG_OPTIONS } + }) + await rig.settle() + views.forEach((view) => view.unmount()) + views = mount() + await waitFor(() => + expect(rows().map((row) => row?.description)).toEqual([description, description]) + ) + expect(rows().every((row) => row?.settable)).toBe(true) + expect((await host.readOptions(sessionId)).current).toMatchObject({ + permissionMode: other, + reportedPermissionMode: mode + }) + expect(rig.sent('session/set_config_option')).toHaveLength(0) + expect(store.getRecord(sessionId)?.lease.runtimeFence).toBe(currentFence) + } finally { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + resetHostModelCatalogSnapshotsForTests() + await rig.adapter.closeAll() + setLocalRuntimeCapabilitiesForTests(null) + } + } +)