Let chats follow the agent permission setting (#32334)

* feat(agents): store each agent's permission itself instead of typing flags into Arguments

Terminals and new chats read one stored choice per agent (Use the agent's own
settings, Ask for approval, Accept edits, Approve for me, Full access). Orca adds
the flag at launch; Arguments hold only the user's own text. A one-time move
lifts Orca's flags out of saved Arguments. Older clients still see and write
the flag as part of Arguments; the host translates.

* fix(agents): say the all-agents switch resets agents set separately; keep primitives unstyled

* test(agents): the one-time move returns every launch-profile field

* Fix permission carrier ownership and surface-specific defaults

* Preserve uncertain permission operands and name the option inventory

* Check env entries without indexing typed agent maps

* Combine Chat permission capability imports

* Use folder execution-host platform for permission environment

* Preserve launch map replacement and acknowledge permission saves

* Keep permission feedback fallbacks statically discoverable

* Provide preload platform to Agents settings render tests

* Preserve Gemini approval arguments under Full access

* fix(settings): retain complete environment edits through save outcomes

* fix(settings): narrow local failure scope and preserve host messages

* fix(settings): retire argument drafts after acknowledged saves

* fix(settings): keep Goose environment editable after reset

* fix(settings): show resolved arguments for legacy hosts

* Preserve pending environment resets and bound launch settings work

* Use agent permission choices for new chats

* Let Claude resolve chat permissions from its own settings

* Keep optional settings fallback in repository metadata reads

* Consolidate permission report imports

* Update permission choice contracts for own settings

* Read repository visibility defaults through the raw settings fixtures

* Match existing translated Agents settings labels

* Update readback and legacy host test expectations

* Align Chat behavior tests with host permission capability

* Fix permission compatibility and host-owned descriptions

* Prepare whole Environment edits and retire unchanged drafts

* Handle null explicitly in permission report decoder

* Keep Environment settings transitions in an acyclic module

* Update raw settings reader test fixtures

* Place test fixture safety notes on assertion starts

* test: pin permission settings work and recovery contracts

* test: type permission settings work fixtures

* test: cover migrated permission inputs and captured CLI grammar

* Publish permission reports and reuse host capability state

* Combine mobile option state React imports

* Honor mobile status feed capability for permission reports

* test: preserve typed permissions in WSL Claude restarts

* Correct permission report child and fixture type contracts

* Delegate older-host chats to explicitly selected own settings

* Avoid rebuilding live ACP option catalogs

* test(chat): place cross-boundary permission contracts in integration project

* Preserve existing chat capability gates and complete report test fixtures

* fix(chat): preserve newer Claude startup permission reports

* test(chat): clarify startup report fixture provenance

* Show reported permission modes in chat menus

* Keep reported permissions available after chat mode picks

* Remove obsolete guard from concrete permission selection

* Report the installed approval policy for ACP chat menus

* test: keep accepted mobile mutation fixture on current fence

* test: cover installed ACP approval report for own chats

* test: align capability scaling contract with concrete permission menus

* Ship required permission help in English runtime catalog

* Adapt permission persistence test to shared menu
This commit is contained in:
Brennan Benson
2026-10-25 16:00:15 -07:00
committed by GitHub
parent 1b0e56e385
commit 23798eff7c
154 changed files with 2741 additions and 1031 deletions
@@ -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'],
@@ -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({
<ModelPill
primary={primary}
secondary={secondary}
warning={fullAccess}
warning={permissionColor !== undefined}
icon={permissionIcon}
accessibleName={
model
? `Model, ${secondary ? `${primary} ${secondary}` : primary}${fullAccess ? ', full access' : ''}`
? `Model, ${secondary ? `${primary} ${secondary}` : primary}${permissionColor ? ', full access' : ''}`
: `Permissions, ${primary}`
}
disabled={busy}
disabled={busy || (!model && permission?.settable === false)}
onPress={() => {
onDrawerOpen(home.id)
Keyboard.dismiss()
@@ -304,7 +309,7 @@ function OptionRows({
<View style={[styles.group, styles.groupSpaced]}>
{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 (
<View key={descriptor.id} style={index < descriptors.length - 1 && styles.divided}>
@@ -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'
}
@@ -33,6 +33,7 @@ describe('structuredAgentSessionHostSupport', () => {
sendOptions: false,
conversationStop: false,
permissionModes: false,
permissionOwn: false,
itemDetail: false,
phoneAttachments: false
}
@@ -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)
@@ -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))
@@ -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({
@@ -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
}
@@ -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<boolean> => {
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)
})
@@ -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({
@@ -61,7 +61,9 @@ describe('useMobileStructuredAgentSession transcript', () => {
async function mount(agent: string, items: AgentJournalRenderItem[]) {
let listener: ((value: unknown) => void) | null = null
const client: Pick<RpcClient, 'sendRequest' | 'subscribe'> = {
const client: Pick<RpcClient, 'sendRequest' | 'subscribe' | 'getState' | 'onStateChange'> = {
getState: () => 'connected',
onStateChange: () => () => {},
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
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,
@@ -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<string, unknown>) {
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<string, unknow
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 3 },
value: {
itemId: String(params?.itemId ?? ''),
itemId: String(fieldsOf(params).itemId ?? ''),
revision: 2,
resolution: {
state: 'resolved',
selectedOptionId: String(params?.optionId ?? ''),
selectedOptionId: String(fieldsOf(params).optionId ?? ''),
resolvedBy: 'mobile',
resolvedAt: 123
}
@@ -210,10 +200,17 @@ describe('useMobileStructuredAgentSession', () => {
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<string, string>
function Harness({
@@ -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 = <T extends { reportedPermissionMode?: string | null }>(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,
@@ -13,6 +13,7 @@ export function useMobileStructuredOptionSurface(args: {
optionSnapshot: SessionOptionDescriptor[]
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
optionStateRef: RefObject<StructuredAgentSessionOptionState>
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]
@@ -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<typeof useMobileStructuredAgentSession> | 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<RpcReply> => ({
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))
})
+4 -1
View File
@@ -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)
})
}
})
@@ -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<Record<string, string>>
permissionMode?: AgentChatPermissionMode
permissionMode?: AgentChatPermissionChoice
}): ReturnType<AcpStructuredSessionAdapter['acquire']>
sent(method: string): FakeFrame[]
frame(method: string, index?: number): Promise<FakeFrame>
@@ -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)
+2 -2
View File
@@ -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()
}
}
+75 -2
View File
@@ -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)
+28 -3
View File
@@ -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<string>()
/** Last successful Orca writes, distinct from agent-reported values. */
/** Installed Orca policy and successful writes, distinct from native agent reports. */
readonly applied = new Map<string, string>()
private initialChoice: Readonly<Record<string, string>> = {}
currentProof = new Set<string>()
@@ -209,6 +209,22 @@ export class AcpStructuredOptions {
}
}
observedCurrent(values?: Readonly<Partial<Record<string, string>>>) {
return { ...this.read().current, reportedPermissionMode: this.reportedPermissionMode(values) }
}
reportedPermissionMode(
values: Readonly<Partial<Record<string, string>>> | 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<typeof acpSessionAcceptsImages>[0] & { options: AcpStructuredOptions }
session: Parameters<typeof acpSessionAcceptsImages>[0] & { options: AcpStructuredOptions },
permissionModes?: Readonly<Partial<Record<string, string>>>
) {
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. */
@@ -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[] = []
@@ -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<AcpStructuredLaunch>
/** The same launch for a read that starts no child (a conversation copy): the agent's env, unsealed. */
readLaunch: (identity: AgentSessionJournalIdentity) => Promise<AcpStructuredLaunch>
@@ -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)
@@ -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[] = []
@@ -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<string, unknown>): ClaudeInitObse
: null
}
/** Init and status frames describe the running mode; absent fields preserve the last report. */
export function readClaudeReportedPermissionMode(
message: Record<string, unknown>
): 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<string, unknown>
): 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
@@ -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<ClaudeAgentSdkOptions, 'permissionMode' | 'extraArgs'> & {
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<ClaudeStructuredSdkOptions, 'permissionMode' | 'extraArgs'> {
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] }
@@ -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<string, ClaudeSession>
acquisitions: ClaudeAcquisitionRegistry
exits: Map<string, ClaudeSessionExit>
callbacks: ClaudeAcquireCallbacks
}): Promise<AgentSessionAcquisition> {
}: ClaudeSessionAcquisitionInput): Promise<AgentSessionAcquisition> {
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<string, unknown>): 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)
@@ -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']
})
})
@@ -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 } : {}),
@@ -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)
@@ -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) {
@@ -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<string, string>
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<string, ClaudeSession>
acquisitions: ClaudeAcquisitionRegistry
exits: Map<string, ClaudeSessionExit>
callbacks: ClaudeAcquireCallbacks
}
export type ClaudeAcquireCallbacks = {
deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void
emit: (
@@ -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'
@@ -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 } : {})
},
@@ -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')
@@ -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
@@ -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',
@@ -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(
@@ -152,6 +152,7 @@ export type CodexSession = {
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
reportedOptions: {
reportedPermissionMode?: string | null
model?: string
effort?: string
serviceTier?: string | null
@@ -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
})
@@ -8,6 +8,7 @@ import { readCodexThreadId } from './codex-structured-thread-facts'
export type CodexOpenedThread = {
threadId: string
reportedPermissionMode?: string | null
supersededThreadId?: string
thread?: Record<string, unknown>
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, unknown>): 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
}
@@ -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<Record<string, string>>
launchPermissionMode?: (
record: AgentSessionRecord
) => Promise<AgentChatPermissionMode | undefined>
) => Promise<AgentChatPermissionChoice | undefined>
/** Publish journal before new-owner sends; distinguish newly starting, retained live and resting owners. */
onAttached: (
attached: AttachedJournal,
@@ -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<string> | undefined
readonly exitObservation?: DeferredStructuredAgentSessionEventSink['exitObservation']
@@ -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<StructuredAgentSessionHostDeps, 'agents' | 'modelCatalog'>,
record: AgentSessionRecord
): Promise<AgentChatPermissionMode | null> {
): Promise<AgentChatPermissionChoice | null> {
const support = agentChatPermissionModes(record.provider)
if (!support || !deps.agents.definition(record.provider)) {
return null
@@ -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
}
@@ -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'
@@ -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<typeof conversation>[0],
work: Partial<typeof idle> = {},
stopAgent = vi.fn(async () => undefined),
launchPermission?: () => Promise<AgentChatPermissionMode | null>
launchPermission?: () => Promise<AgentChatPermissionChoice | null>
) => {
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<AgentChatPermissionMode | null>
launched: AgentChatPermissionChoice,
current: () => Promise<AgentChatPermissionChoice | null>
) =>
(await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current))
.stopAgent.mock.calls.length > 0
@@ -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<StructuredAgentSessionHostSession, 'child'> & {
journal: Pick<AgentSessionJournal, 'visitItemsWithLinkage' | 'itemFence' | 'newestTurn'> & {
@@ -68,7 +68,7 @@ function owesWakeUp(
/** Compare the actual launch mode so replacement cannot loop. */
async function launchedWithOtherPermissionMode(
child: OutgrownChildReading['child'],
current: (() => Promise<AgentChatPermissionMode | null>) | undefined
current: (() => Promise<AgentChatPermissionChoice | null>) | undefined
): Promise<boolean> {
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<AgentChatPermissionMode | null>
launchPermission?: () => Promise<AgentChatPermissionChoice | null>
stopAgent: (sessionId: string) => Promise<void>
logger: StructuredAgentSessionLogger
}
@@ -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<void> {
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,
@@ -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
}
@@ -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<AgentSessionRecord, 'location' | 'accountHome' | 'launchDirectory'>
/** Saved launch intent, never catalog guesses. */
readonly options?: Readonly<Record<string, string>>
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
@@ -93,7 +93,9 @@ async function startHost(): Promise<void> {
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)',
@@ -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<Record<string, string>>
@@ -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
})
}
}
@@ -155,6 +155,7 @@ describe('StructuredAgentSessionStatusFeed', () => {
agent: 'codex',
status: null,
latestPrompt: '',
runtimePermission: null,
updatedAt: expect.any(Number)
}
]
@@ -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() }])
@@ -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
)
)
}
@@ -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<StructuredAgentSessionProviderChild, 'phase'> | 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 }
@@ -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<GlobalSettings>
): Partial<GlobalSettings> {
const choice =
'nativeChatPermissionMode' in updates && !('agentPermissionDefault' in updates)
? applyAllAgentsPermission(
settings,
isAgentChatPermissionMode(updates.nativeChatPermissionMode)
? updates.nativeChatPermissionMode
: 'ask'
)
: {}
return {
...choice,
nativeChatPermissionMode: publishedLegacyChatPermissionMode({
...settings,
...updates,
...choice
})
}
}
@@ -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' })
})
})
@@ -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)
@@ -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<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const {
agentNativeChatPermissionModes: _retiredChatPermissionCopies,
setupScriptLaunchMode: _legacySetupScriptLaunchMode,
terminalScrollbackBytes: _legacyScrollbackBytes,
enableGitHubAttribution: _legacyGitHubAttribution,
@@ -83,6 +85,7 @@ export function stripRetiredGlobalSettings(
openAgentTabsInChatByDefault: _legacyChatDefaultView,
...rest
} = (settings ?? {}) as Partial<GlobalSettings> & RetiredGlobalSettings
void _retiredChatPermissionCopies
void _legacySetupScriptLaunchMode
void _legacyScrollbackBytes
void _legacyGitHubAttribution
@@ -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<GlobalSettings>
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(
@@ -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,
@@ -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' }
@@ -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' })
})
@@ -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')
})
@@ -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
+4 -11
View File
@@ -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 }
@@ -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']
@@ -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<L>(
return (input: {
identity: AgentSessionJournalIdentity
spawnToken?: string | null
permissionMode?: AgentChatPermissionMode
permissionMode?: AgentChatPermissionChoice
}) => {
const spawnToken = input.spawnToken === undefined ? defaultToken : input.spawnToken
return spawnToken === null
@@ -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<void>
/** 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<L>(
sources: StructuredAgentLaunchSources,
part: StructuredAgentLaunchPartResolver<L>,
identity: AgentSessionJournalIdentity,
permissionMode?: AgentChatPermissionMode
permissionMode?: AgentChatPermissionChoice
): Promise<StructuredAgentLaunchPart<L> & { cwd: string }> {
const record = admittedRecord(definition, sources.store, identity.sessionId)
const launchEnv = once(() => sources.resolveAgentLaunchEnv?.(definition.agent) ?? {})
@@ -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<string, string | number>) =>
@@ -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(
<NativeChatComposerPermissionPicker surface={options} snapshot={snapshot()} agent={agent} />
)
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true')
for (const report of [mode, null, mode, undefined]) {
rerender(
<NativeChatComposerPermissionPicker
surface={options}
snapshot={snapshot(report)}
agent={agent}
/>
)
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(
<NativeChatComposerPermissionPicker
surface={options}
snapshot={snapshot(mode)}
agent={agent}
/>
)
fireEvent.click(option('full-access'))
expect(options.setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access')
}
)
it('renders nothing without a permission descriptor', () => {
const { container } = render(
<NativeChatComposerPermissionPicker surface={surface()} snapshot={[]} agent="codex" />
@@ -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(
<NativeChatComposerPermissionPicker
@@ -221,7 +282,7 @@ describe('NativeChatComposerPermissionPicker', () => {
/>
)
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'))
@@ -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 (
<DropdownMenu>
@@ -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"
>
<NativeChatPermissionModeName mode={current} truncate />
{isAgentChatPermissionMode(current) ? (
<NativeChatPermissionModeName mode={current} truncate />
) : (
<>
<Settings2 className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{label}</span>
</>
)}
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
@@ -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 (
<NativeChatPermissionModePicker
current={current}
modes={modes}
currentLabel={descriptor.description}
agent={agent ?? null}
disabled={!descriptor.settable}
onSelect={(mode) => {
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)
)
}}
/>
)
@@ -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', () => ({
@@ -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
}))
@@ -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
}))
@@ -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
}))
@@ -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
}))
@@ -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)
}))
@@ -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),
@@ -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<typeof structuredAgentSessionOptionView>) => {
const view = structuredAgentSessionOptionView(...args)
// What the picker shows and a send carries.
optionView: (
...args: Parameters<typeof structuredAgentSessionOptionView>
): StructuredAgentSessionOptionState => {
const view = {
...projectRuntimePermission(structuredAgentSessionOptionView(...args), summary, fence),
permissionOwnSupported
}
return pendingOptionsRef.current.model === undefined ? view : { ...view, pendingId: 'model' }
},
takesSendOptions,
@@ -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()
@@ -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', () => ({
@@ -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<string, string> }>()
@@ -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']
@@ -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
}))
@@ -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 ||
@@ -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
@@ -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)
}))
@@ -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)
}))
@@ -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,
@@ -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)
@@ -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)
}))
@@ -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)
}))
@@ -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)
}))
@@ -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)
}))
@@ -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)
}))
@@ -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)
}))

Some files were not shown because too many files have changed in this diff Show More