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) + } + } +)