feat(native-chat): each Claude/Codex chat gets its own permission mode (#26116)

Each native Claude and Codex chat now has its own permission mode (Ask for approval, Accept edits for Claude, Approve for me, Full access), shown and changed from a button right after "+" in the composer. A change takes effect before the next message: the agent restarts quietly and resumes the same conversation, never interrupting a reply or background work. New chats start from Settings → Chat → Permissions, which is seeded once from Agent Permissions. The mode is stored as an ordinary chat option, and the restart reuses the existing "replace the agent before the next message" step.
This commit is contained in:
Brennan Benson
2026-10-12 15:36:45 -07:00
committed by GitHub
parent f89d51ba02
commit 2233fd3669
136 changed files with 3426 additions and 805 deletions
@@ -72,6 +72,8 @@ export const NODE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts',
'src/main/native-chat/structured-chat-naming-command-first.test.ts',
'src/main/native-chat/structured-chat-naming-status-hook.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
@@ -290,4 +290,35 @@ describe('MobileNativeChatSessionOptionPickers', () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true)
expect(pill('Model').props).toMatchObject({ disabled: true })
})
it('shows the permission pill before any model pill, and closes at once on a pick', async () => {
mount([PERMISSION_DESCRIPTOR], true)
expect(pill('Permissions').props).toMatchObject({
accessibilityLabel: 'Permissions, Ask for approval',
disabled: false
})
// A write that waits behind a starting agent never holds the sheet open.
setOption.mockImplementation(() => new Promise(() => {}))
await act(async () => pill('Permissions').props.onPress())
await act(async () => rowByText('Full access').props.onPress())
expect(setOption).toHaveBeenCalledWith('permissionMode', 'full-access')
expect(renderer!.root.findAllByType('BottomDrawer')).toEqual([])
})
})
const PERMISSION_DESCRIPTOR: SessionOptionDescriptor = {
id: 'permissionMode',
label: 'Permissions',
category: 'mode',
kind: {
type: 'select',
currentValue: 'ask',
choices: [
{ value: 'ask', label: 'Ask for approval' },
{ value: 'full-access', label: 'Full access' }
]
},
valueSource: 'applied',
transport: 'agent-session',
settable: true
}
@@ -21,6 +21,7 @@ import {
SessionOptionSummaryRow
} from './MobileNativeChatSessionOptionRows'
import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
export type MobileNativeChatSessionOptionPickersProps = {
@@ -35,7 +36,12 @@ export type MobileNativeChatSessionOptionPickersProps = {
sendInFlight?: boolean
}
/** Combined model/session-option trigger and its mobile bottom drawer. */
/** Full access reads in the warning colour wherever it is shown. */
function permissionChoiceColor(value: string | undefined): string | undefined {
return value === 'full-access' ? colors.statusAmber : undefined
}
/** The permission pill, then the combined model/session-option pill, sharing one bottom drawer. */
export function MobileNativeChatSessionOptionPickers({
controller,
isWorking,
@@ -49,14 +55,19 @@ export function MobileNativeChatSessionOptionPickers({
}
const { snapshot, pendingId } = controller
const model = snapshot.find((descriptor) => descriptor.category === 'model')
const permission = snapshot.find(
(descriptor) => descriptor.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
)
const options = sortNativeChatSessionOptions(snapshot)
if (!model) {
if (!model && !permission) {
return null
}
const disabled = isWorking || pendingId !== null || sendInFlight
const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId)
const modelView = activeDescriptor?.id === model.id
const modelLabel = mobileModelPillLabel(model)
const permissionView = activeDescriptor !== undefined && activeDescriptor === permission
// The permission mode applies from the next send, so a running turn never locks it.
const disabled = (!permissionView && isWorking) || pendingId !== null || sendInFlight
const modelView = model !== undefined && activeDescriptor?.id === model.id
const modelLabel = model ? mobileModelPillLabel(model) : ''
const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null
const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel
const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason)
@@ -64,7 +75,7 @@ export function MobileNativeChatSessionOptionPickers({
const closePicker = (): void => setOpenDescriptorId(null)
const openPicker = (): void => {
Keyboard.dismiss()
setOpenDescriptorId(model.id)
setOpenDescriptorId(model?.id ?? null)
}
const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
@@ -77,6 +88,10 @@ export function MobileNativeChatSessionOptionPickers({
closePicker()
return
}
// A permission pick shows at once; its write may wait behind a starting agent.
if (descriptor === permission) {
closePicker()
}
void controller.setOption(descriptor.id, value).then((applied) => {
if (applied) {
closePicker()
@@ -91,26 +106,46 @@ export function MobileNativeChatSessionOptionPickers({
})
}
const permissionChoice =
permission?.kind.type === 'select'
? permission.kind.choices.find((choice) => choice.value === permission.kind.currentValue)
: undefined
const pillsBusy = pendingId !== null || sendInFlight
return (
<View style={styles.pillSlot}>
<Pill
label={pillLabel}
accessibleName={`Model, ${pillLabel}`}
disabled={disabled}
onPress={openPicker}
/>
<View style={styles.pills}>
{permission && permissionChoice ? (
<Pill
label={permissionChoice.label}
accessibleName={`Permissions, ${permissionChoice.label}`}
disabled={pillsBusy}
labelColor={permissionChoiceColor(permissionChoice.value)}
onPress={() => {
Keyboard.dismiss()
setOpenDescriptorId(permission.id)
}}
/>
) : null}
{model ? (
<Pill
label={pillLabel}
accessibleName={`Model, ${pillLabel}`}
disabled={isWorking || pillsBusy}
onPress={openPicker}
/>
) : null}
<BottomDrawer visible={activeDescriptor !== undefined} onClose={closePicker}>
{activeDescriptor ? (
<View style={styles.sheet}>
<View style={styles.sheetHeader}>
<Pressable
accessibilityLabel={modelView ? 'Close picker' : 'Back to models'}
accessibilityLabel={modelView || permissionView ? 'Close picker' : 'Back to models'}
accessibilityRole="button"
style={({ pressed }) => [styles.sheetNav, pressed && styles.pressed]}
onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)}
onPress={modelView || permissionView ? closePicker : openPicker}
hitSlop={8}
>
{modelView ? (
{modelView || permissionView ? (
<X size={18} color={colors.textSecondary} strokeWidth={2.2} />
) : (
<ChevronLeft size={18} color={colors.textSecondary} strokeWidth={2.2} />
@@ -136,6 +171,7 @@ export function MobileNativeChatSessionOptionPickers({
grouped
onSetOption={(value) => applyOption(activeDescriptor, value)}
onInvokeAction={() => invokeAction(activeDescriptor)}
{...(permissionView ? { choiceLabelColor: permissionChoiceColor } : {})}
/>
</View>
{modelView && options.length > 0 ? (
@@ -161,9 +197,12 @@ export function MobileNativeChatSessionOptionPickers({
const styles = StyleSheet.create({
// The composer row's only shrinkable item: a long label truncates before it pushes Send off.
pillSlot: {
pills: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0
minWidth: 0,
gap: spacing.xs
},
sheet: {
paddingBottom: spacing.xs
@@ -20,12 +20,14 @@ export function Pill({
label,
accessibleName,
disabled,
onPress
onPress,
labelColor
}: {
label: string
accessibleName: string
disabled: boolean
onPress: () => void
labelColor?: string
}): React.JSX.Element {
return (
<Pressable
@@ -38,7 +40,11 @@ export function Pill({
hitSlop={6}
>
<Text
style={[styles.pillText, disabled && styles.pillTextDisabled]}
style={[
styles.pillText,
labelColor ? { color: labelColor } : null,
disabled && styles.pillTextDisabled
]}
numberOfLines={1}
ellipsizeMode="tail"
>
@@ -56,7 +62,8 @@ function ChoiceRow({
disabled,
grouped,
divided,
onPress
onPress,
labelColor
}: {
label: string
description?: string
@@ -65,6 +72,7 @@ function ChoiceRow({
grouped: boolean
divided: boolean
onPress: () => void
labelColor?: string
}): React.JSX.Element {
return (
<Pressable
@@ -84,7 +92,7 @@ function ChoiceRow({
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.rowBody}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={[styles.rowLabel, labelColor ? { color: labelColor } : null]}>{label}</Text>
{description ? (
<Text style={styles.rowDescription} numberOfLines={2}>
{description}
@@ -198,13 +206,16 @@ export function DescriptorRows({
disabled,
grouped = false,
onSetOption,
onInvokeAction
onInvokeAction,
choiceLabelColor
}: {
descriptor: SessionOptionDescriptor
disabled: boolean
grouped?: boolean
onSetOption: (value: SessionOptionValue) => void
onInvokeAction: () => void
/** A choice whose name reads in a colour of its own, as Full access does. */
choiceLabelColor?: (value: string) => string | undefined
}): React.JSX.Element {
const locked = disabled || !descriptor.settable
// Why: flip-only without a baseline is an action — never claim On/Off.
@@ -257,6 +268,7 @@ export function DescriptorRows({
grouped={grouped}
divided={grouped && index < choices.length - 1}
onPress={() => onSetOption(choice.value)}
labelColor={choiceLabelColor?.(choice.value)}
/>
))}
</>
@@ -32,6 +32,8 @@ export function useMobileHostModelCatalogUpgrade(args: {
fence: number | null
optionCatalog: AgentSessionOptionCatalog | null
activeOptionRecordRef: MutableRefObject<NativeChatSessionOptionRecord>
/** A listing landed after the chat's options were read, which answered without it. */
onListingLanded?: () => void
updateOptionState: (
update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
) => void
@@ -43,6 +45,7 @@ export function useMobileHostModelCatalogUpgrade(args: {
enabled,
fence,
newLaunch,
onListingLanded,
optionCatalog,
sessionId,
updateOptionState,
@@ -89,7 +92,12 @@ export function useMobileHostModelCatalogUpgrade(args: {
if (catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale) {
// Usable on the built-in list while the listing runs; it lands in place.
apply(catalog)
return read(true).then(apply)
return read(true).then((listed) => {
apply(listed)
if (!stale && listed.origin !== 'unknown') {
onListingLanded?.()
}
})
}
apply(catalog)
return undefined
@@ -105,6 +113,7 @@ export function useMobileHostModelCatalogUpgrade(args: {
enabled,
fence,
newLaunch,
onListingLanded,
optionCatalog,
sessionId,
updateOptionState,
@@ -0,0 +1,213 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
function optionsFor(permissionMode?: string): AgentSessionOptionsResult {
return {
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live', ...(permissionMode ? { permissionMode } : {}) }
}
}
/** Answers each options read with the next mode in `modes`, repeating the last. */
function optionsClient(...modes: (string | undefined)[]) {
let reads = 0
const client: RpcClient = {
sendRequest: async (method: string) => {
if (method === 'agentSession.modelCatalog') {
return {
id: 'rpc-1',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
}
const result =
method === 'agentSession.options'
? optionsFor(modes[Math.min(reads++, modes.length - 1)])
: {}
return { id: 'rpc-1', ok: true as const, result, _meta: { runtimeId: 'runtime-1' } }
},
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
return { client, reads: () => reads }
}
type Controller = ReturnType<typeof useMobileStructuredAgentOptions>
type ProbeProps = { client: RpcClient; fence: number; optionStamp: string; agent?: string }
const mutate: StructuredAgentSessionMutate = vi.fn()
async function mountOptions(props: ProbeProps) {
const rendered: { current: Controller | null } = { current: null }
function Probe(probe: ProbeProps): null {
rendered.current = useMobileStructuredAgentOptions({
agent: probe.agent ?? 'codex',
client: probe.client,
sessionId: 'session-1',
enabled: true,
fence: probe.fence,
mutate,
optionStamp: probe.optionStamp
})
return null
}
let renderer: ReactTestRenderer | null = null
const settle = () =>
act(async () => {
await Promise.resolve()
await Promise.resolve()
})
await act(async () => {
renderer = create(createElement(Probe, props))
})
await settle()
return {
offered: () => {
const entry = rendered.current?.optionSnapshot.find(
(descriptor) => descriptor.id === 'permissionMode'
)
return entry?.kind.type === 'select' ? entry.kind.choices.map((choice) => choice.value) : []
},
permission: () => {
const entry = rendered.current?.optionSnapshot.find(
(descriptor) => descriptor.id === 'permissionMode'
)
return entry?.kind.type === 'select' ? entry.kind.currentValue : null
},
rerender: async (next: Partial<ProbeProps>) => {
await act(async () => {
renderer?.update(createElement(Probe, { ...props, ...next }))
})
await settle()
},
pick: async (mode: string) => {
await act(async () => {
void rendered.current?.setStructuredOption('permissionMode', mode)
})
await settle()
},
unmount: () => act(async () => renderer?.unmount())
}
}
describe('the permission mode on the phone', () => {
it('re-reads once when the host status moves, so a desktop pick shows here', async () => {
const rpc = optionsClient('ask', 'full-access')
const options = await mountOptions({
client: rpc.client,
fence: 1,
optionStamp: 'gpt-live|ask'
})
expect(options.permission()).toBe('ask')
expect(rpc.reads()).toBe(1)
await options.rerender({ optionStamp: 'gpt-live|full-access' })
expect(options.permission()).toBe('full-access')
expect(rpc.reads()).toBe(2)
await options.unmount()
})
it('keeps the mode across a fence of the same chat', async () => {
const rpc = optionsClient('auto')
const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' })
expect(options.permission()).toBe('auto')
await options.rerender({ fence: 2 })
expect(options.permission()).toBe('auto')
await options.unmount()
})
it('shows a pick at once while its write waits behind a start, and stays pickable', async () => {
vi.mocked(mutate).mockImplementation(() => new Promise(() => {}))
const rpc = optionsClient('ask')
const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' })
await options.pick('full-access')
expect(options.permission()).toBe('full-access')
await options.pick('auto')
expect(options.permission()).toBe('auto')
expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2)
await options.unmount()
})
it('shows no pill for a host that states no mode', async () => {
const rpc = optionsClient(undefined)
const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' })
expect(rpc.reads()).toBe(1)
expect(options.permission()).toBeNull()
await options.unmount()
})
})
/** A host whose account listing lands only after the chat's first options read. */
function listingLandsLaterClient() {
let landed = false
let land: () => void = () => {}
const lands = new Promise<void>((resolve) => (land = resolve))
const rows = [
{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts: [], supportsAutoMode: true },
{ id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }
]
const answer = async (method: string, waitForListing: boolean) => {
if (method === 'agentSession.modelCatalog') {
if (!waitForListing) {
return { origin: 'unknown', listingInProgress: true }
}
await lands
return { origin: 'probe', fetchedAt: 1, models: rows }
}
// At rest, a read before the listing lands answers from the agent's built-in list.
return landed
? { models: rows, current: { model: 'opus[1m]', permissionMode: 'ask' } }
: {
models: [{ id: 'sonnet', label: 'Sonnet', isDefault: true, efforts: [] }],
current: { permissionMode: 'ask' }
}
}
const client: RpcClient = {
...optionsClient().client,
sendRequest: async (method: string, params?: unknown) => ({
id: 'rpc-1',
ok: true as const,
result: await answer(
method,
typeof params === 'object' && params !== null && 'waitForListing' in params
),
_meta: { runtimeId: 'runtime-1' }
})
}
return {
client,
land: () => {
landed = true
land()
}
}
}
describe('Approve for me on a phone chat opened before its account was listed', () => {
it('is offered once the listing lands', async () => {
const host = listingLandsLaterClient()
const options = await mountOptions({
client: host.client,
fence: 1,
optionStamp: '',
agent: 'claude'
})
expect(options.offered()).toEqual(['ask', 'accept-edits', 'full-access'])
host.land()
await options.rerender({})
expect(options.offered()).toContain('auto')
await options.unmount()
})
})
@@ -21,6 +21,7 @@ import {
structuredAgentSessionOptionSnapshot,
type StructuredAgentSessionOptionState
} from '../../../src/shared/structured-agent-session-options'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode'
import type { RpcClient } from '../transport/rpc-client'
import {
callAgentSession,
@@ -53,8 +54,10 @@ export function useMobileStructuredAgentOptions(args: {
enabled: boolean
fence: number | null
mutate: StructuredAgentSessionMutate
/** Moves when the host's status says a session option changed; each move re-reads them. */
optionStamp?: string
}): StructuredOptionsController {
const { agent, client, enabled, fence, mutate, sessionId } = args
const { agent, client, enabled, fence, mutate, optionStamp, sessionId } = args
// Every agent's seed, as on the desktop: a built-in list or the provider-default pill.
const optionCatalog = useMemo(
() => (agent ? structuredAgentSessionSeedCatalog(agent) : null),
@@ -96,10 +99,12 @@ export function useMobileStructuredAgentOptions(args: {
const previous = optionStateRef.current
const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog)
// A host answer is the account's, not the fence's: keep it rather than fall back to the placeholder.
const next =
const kept =
sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin')
? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource }
: seeded
// The chat's own, which a restart for a new mode keeps: the pill never blanks between them.
const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept
optionMutationGeneration.current += 1
pendingOptionRef.current = null
optionStateRef.current = next
@@ -112,6 +117,9 @@ export function useMobileStructuredAgentOptions(args: {
() => (sessionId ? mobileCreatedStructuredSession(sessionId) : undefined),
[sessionId]
)
// An options answer made before the account's listing landed listed none of it.
const [listingsLanded, setListingsLanded] = useState(0)
const onListingLanded = useCallback(() => setListingsLanded((count) => count + 1), [])
useMobileHostModelCatalogUpgrade({
agent,
client,
@@ -122,7 +130,8 @@ export function useMobileStructuredAgentOptions(args: {
...(createdHere ? { worktree: createdHere.worktree } : {}),
optionCatalog,
activeOptionRecordRef,
updateOptionState
updateOptionState,
onListingLanded
})
useEffect(() => {
@@ -152,7 +161,16 @@ export function useMobileStructuredAgentOptions(args: {
return () => {
stale = true
}
}, [client, enabled, optionCatalog, sessionId, fence, updateOptionState])
}, [
client,
enabled,
optionCatalog,
optionStamp,
listingsLanded,
sessionId,
fence,
updateOptionState
])
const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
@@ -175,18 +193,30 @@ export function useMobileStructuredAgentOptions(args: {
}
const targetRecord = currentState.record
const mutationGeneration = ++optionMutationGeneration.current
pendingOptionRef.current = id
updateOptionState((current) => ({ ...current, pendingId: id }))
const isCurrent = (): boolean =>
activeOptionRecordRef.current === targetRecord &&
optionMutationGeneration.current === mutationGeneration
// Record-only: shown at once, so the picker stays usable while the write waits behind a start.
const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
if (shownAtOnce) {
updateOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOption(current, id, encoded)
: current
)
} else {
pendingOptionRef.current = id
updateOptionState((current) => ({ ...current, pendingId: id }))
}
// A same-fence accepted write, or a shown one that failed, reads back what the host now holds.
let readsBack = shownAtOnce
try {
const result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value: encoded }
)
if (
activeOptionRecordRef.current !== targetRecord ||
optionMutationGeneration.current !== mutationGeneration
) {
if (!isCurrent()) {
return result.status !== 'rejected'
}
if (result.status === 'accepted') {
@@ -206,27 +236,11 @@ export function useMobileStructuredAgentOptions(args: {
picks: structuredAgentSessionOptionPicks(currentState, committed)
})
}
if (result.sameFence) {
void callAgentSession<AgentSessionOptionsResult>(client, 'agentSession.options', {
sessionId
})
.then((refreshed) => {
if (
activeOptionRecordRef.current === targetRecord &&
optionMutationGeneration.current === mutationGeneration
) {
updateOptionState((latest) =>
latest.record === targetRecord
? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed)
: latest
)
}
})
.catch(() => undefined)
}
readsBack = result.sameFence
return true
}
if (result.status === 'unknown') {
readsBack = false
updateOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOption(current, id, encoded)
@@ -236,10 +250,22 @@ export function useMobileStructuredAgentOptions(args: {
}
return false
} finally {
if (
activeOptionRecordRef.current === targetRecord &&
optionMutationGeneration.current === mutationGeneration
) {
if (readsBack && isCurrent()) {
void callAgentSession<AgentSessionOptionsResult>(client, 'agentSession.options', {
sessionId
})
.then((refreshed) => {
if (isCurrent()) {
updateOptionState((latest) =>
latest.record === targetRecord
? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed)
: latest
)
}
})
.catch(() => undefined)
}
if (isCurrent()) {
pendingOptionRef.current = null
updateOptionState((current) =>
current.record === targetRecord && current.pendingId === id
@@ -25,6 +25,7 @@ import { useMobileStructuredSessionHostStopping } from './use-mobile-structured-
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
import { useMobileStructuredSessionOptionStamp } from './use-mobile-structured-session-option-stamp'
import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing'
import {
pendingStructuredPromptIdentity,
@@ -92,13 +93,19 @@ export function useMobileStructuredAgentSession(args: {
onSendError
})
const optionStamp = useMobileStructuredSessionOptionStamp({
client,
sessionId,
enabled: enabled && connected && hostSupport?.statusFeed === true
})
const options = useMobileStructuredAgentOptions({
agent,
client,
sessionId,
enabled,
fence: state.fence,
mutate
mutate,
optionStamp
})
const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } =
options
@@ -0,0 +1,24 @@
import { useCallback, useSyncExternalStore } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed'
const noSubscription = (): (() => void) => () => {}
/** The host status's option values as one stamp; another device's pick moves it, off `enabled` never. */
export function useMobileStructuredSessionOptionStamp(args: {
client: RpcClient | null
sessionId: string | null
enabled: boolean
}): string {
const { client, sessionId, enabled } = args
const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null
const subscribe = useCallback(
(listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()),
[feed]
)
const readStamp = (): string => {
const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined
return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}`
}
return useSyncExternalStore(subscribe, readStamp, readStamp)
}
@@ -8,7 +8,7 @@ import {
import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn'
import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry'
import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-signed-out-child'
import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-outgrown-child'
import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs'
import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions'
import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support'
@@ -6,7 +6,6 @@ import {
query,
type CanUseTool,
type Options,
type PermissionMode,
type SDKUserMessage,
type SpawnedProcess as SdkSpawnedProcess,
type SpawnOptions as SdkSpawnOptions
@@ -14,6 +13,7 @@ import {
import { afterEach, describe, expect, it, vi } from 'vitest'
import { spawnProcess } from '@orca/process-host'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests'
@@ -131,7 +131,7 @@ function recordingSpawner(spawns: SpawnSeen[]) {
}
}
function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = []) {
function resolvedLaunch(permissionMode: AgentChatPermissionMode, launchArgs: string[] = []) {
const record = {
sessionId: 'contract-pin-session',
provider: 'claude',
@@ -150,9 +150,17 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async () => '/repos/workspace-1',
resolveCommand: () => FAKE_CLI,
resolveAuthPolicy: () => ({ account: 'managed' }),
resolvePermissionMode: () => permissionMode
})({ identity: { sessionId: record.sessionId } as never })
resolveAuthPolicy: () => ({ account: 'managed' })
})({
identity: {
sessionId: record.sessionId,
workspaceId: 'workspace-1',
hostId: LOCAL_EXECUTION_HOST_ID,
agent: 'claude',
providerHandle: null
},
permissionMode
})
}
function singleUserTurn(): AsyncIterable<SDKUserMessage> {
@@ -330,7 +338,7 @@ describe('Claude Agent SDK contract pins', () => {
const spawns: SpawnSeen[] = []
// Driven by the real resolver, so the argv walk covers its option set and merge order,
// not a hand-written options literal.
const launch = await resolvedLaunch('bypassPermissions', [
const launch = await resolvedLaunch('full-access', [
'--model',
'claude-sonnet-4-5',
'--dangerously-skip-permissions',
@@ -101,6 +101,32 @@ describe('claude model catalog probe', () => {
}
)
// The rows `claude` 2.1.280 lists: what a chat naming no model runs, and its auto support.
it('marks the row the CLI default resolves to and keeps each row auto support', async () => {
const listing = `${JSON.stringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'orca-model-discovery',
response: {
models: [
{ value: 'default', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true },
{ value: 'opus[1m]', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true },
{ value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', supportsAutoMode: null }
]
}
}
})}\n`
const probe = createClaudeModelCatalogProbe({ ...probeDeps(), runListing: async () => listing })
const { models } = await probe(HOME('/homes/account-a'))
expect(
models.map(({ id, isDefault, supportsAutoMode }) => ({ id, isDefault, supportsAutoMode }))
).toEqual([
{ id: 'opus[1m]', isDefault: true, supportsAutoMode: true },
{ id: 'haiku', isDefault: false, supportsAutoMode: undefined }
])
})
it('pins no CLAUDE_CONFIG_DIR for the CLI default home, exactly as a session spawn does', async () => {
const envs: (NodeJS.ProcessEnv | undefined)[] = []
const probe = createClaudeModelCatalogProbe({
+10 -2
View File
@@ -1,6 +1,7 @@
import {
CLAUDE_MODEL_LIST_ARGS,
CLAUDE_MODEL_LIST_STDIN
CLAUDE_MODEL_LIST_STDIN,
parseClaudeModelList
} from '../../shared/claude-model-list-probe'
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { parseClaudeModels } from '../../shared/commit-message-model-parsers'
@@ -57,12 +58,16 @@ export function createClaudeModelCatalogProbe(
if (listed.length === 0) {
throw new Error('claude listed no models')
}
// Only the catalog marks the CLI default; the commit-message parse keeps its own default.
const runsDefault = new Set(
parseClaudeModelList(stdout).flatMap((model) => (model.isDefault ? [model.id] : []))
)
return {
models: listed.map((model) => ({
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
isDefault: model.isDefault === true,
isDefault: runsDefault.has(model.id),
// No defaultEffort: the parser's thinking default is the commit-message generator's
// choice, not the effort Claude runs; the store keeps the one a live child reported.
efforts: (model.thinkingLevels ?? []).map((level) => ({
@@ -71,6 +76,9 @@ export function createClaudeModelCatalogProbe(
})),
...(model.supportsFastMode !== undefined
? { supportsFastMode: model.supportsFastMode }
: {}),
...(model.supportsAutoMode !== undefined
? { supportsAutoMode: model.supportsAutoMode }
: {})
})),
origin: 'probe'
@@ -89,7 +89,11 @@ export async function resolveClaudeAcquisitionLaunch(args: {
}
: input.identity
const launch = await deps
.resolveLaunch({ identity: launchIdentity, spawnToken: input.spawnToken })
.resolveLaunch({
identity: launchIdentity,
spawnToken: input.spawnToken,
...(input.permissionMode ? { permissionMode: input.permissionMode } : {})
})
.catch((error: unknown) => {
throw error instanceof AgentSessionPreSpawnError
? error
@@ -41,7 +41,8 @@ function wireClaudeModel(entry: ListedModel): WireClaudeModel {
...(entry.description ? { description: entry.description } : {}),
isDefault: entry.isDefault,
efforts: entry.efforts,
...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {})
...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}),
...(entry.supportsAutoMode !== undefined ? { supportsAutoMode: entry.supportsAutoMode } : {})
}
}
@@ -390,7 +390,7 @@ describe('Claude effort default at rest', () => {
const result = await readAtRest(store, record)
expect(result.current).toEqual({ model: 'gpt-5.5' })
expect(result.current).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' })
})
it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => {
@@ -12,7 +12,6 @@ import {
CLAUDE_STRUCTURED_BASE_OPTIONS,
claudeSessionIdForOrcaSession
} from './claude-structured-launch-resolution'
import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode'
import { CLAUDE_THINKING_DISPLAY_FLAG, type ClaudeCliFlag } from './claude-cli-flag-support'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
@@ -56,8 +55,6 @@ function resolverFor(
value: AgentSessionRecord | null,
resolveEnv?: () => Record<string, string>,
account: 'managed' | 'system' = 'system',
// Manual by default so a test that is not about permissions is not silently about them.
agentDefaultArgs: Record<string, string> = { claude: '' },
hasTranscript: () => Promise<boolean> = async () => true,
resolveLaunchArgs?: () => string[],
attachmentDirectory?: string
@@ -67,7 +64,6 @@ function resolverFor(
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => '/usr/local/bin/claude',
resolveAuthPolicy: () => ({ account }),
resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }),
hasTranscript,
resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []),
...(resolveEnv ? { resolveEnv } : {}),
@@ -116,6 +112,8 @@ describe('claude structured launch resolution', () => {
supportedDialogKinds: [],
extraArgs: { 'replay-user-messages': null },
systemPrompt: { type: 'preset', preset: 'claude_code' },
// A start the host named no mode for asks.
permissionMode: 'default',
sessionId: first.providerSessionId
})
expect(first.options.resume).toBeUndefined()
@@ -136,7 +134,6 @@ describe('claude structured launch resolution', () => {
value,
undefined,
'system',
undefined,
hasTranscript
)({ identity: identityAt('old-leaf') })
const expected = claudeSessionIdForOrcaSession(SESSION_ID, boundary.operationId)
@@ -153,7 +150,6 @@ describe('claude structured launch resolution', () => {
value,
undefined,
'system',
undefined,
hasTranscript
)({ identity: IDENTITY })
expect(retry.providerSessionId).toBe(expected)
@@ -161,7 +157,6 @@ describe('claude structured launch resolution', () => {
{ ...value, providerContextBoundary: { ...boundary, operationId: 'clear-two' } },
undefined,
'system',
undefined,
async () => false
)({ identity: IDENTITY })
expect(next.providerSessionId).not.toBe(expected)
@@ -260,7 +255,6 @@ describe('claude structured launch resolution', () => {
}),
undefined,
'system',
{ claude: '' },
hasTranscript
)({ identity: identityAt(null) })
@@ -279,15 +273,11 @@ describe('claude structured launch resolution', () => {
})
})
// Agent Permissions is stored as the bypass flag inside the launch arguments, so presence of
// that flag — not the whole string — is what Yolo means, exactly as a terminal launch reads it.
it.each([
['--dangerously-skip-permissions'],
['--dangerously-skip-permissions --model Opus'],
['--model Opus --dangerously-skip-permissions']
])('starts a Yolo session in bypassPermissions for args %s', async (claude) => {
const launch = await resolverFor(record(), undefined, 'system', { claude })({
identity: IDENTITY
// Full access is the owned bypass flag, never the SDK's typed option older binaries reject.
it('starts a Full access chat with the bypass flag and no permission mode', async () => {
const launch = await resolverFor(record())({
identity: IDENTITY,
permissionMode: 'full-access'
})
expect(launch.options.extraArgs).toEqual({
@@ -298,31 +288,30 @@ describe('claude structured launch resolution', () => {
expect(launch.options.allowDangerouslySkipPermissions).toBeUndefined()
})
// The common profile: the toggle has never been used, so it has written nothing, and the
// default for the key it did not write is the bypass flag — the posture the terminal has
// always given these users.
it('starts a session that never opened Agent settings in bypassPermissions', async () => {
const launch = await resolverFor(record(), undefined, 'system', {})({ identity: IDENTITY })
it.each([
['ask', 'default'],
['accept-edits', 'acceptEdits'],
['auto', 'auto']
] as const)('starts a %s chat in permission mode %s', async (permissionMode, expected) => {
const launch = await resolverFor(record())({ identity: IDENTITY, permissionMode })
expect(launch.options.extraArgs).toEqual({
'replay-user-messages': null,
'dangerously-skip-permissions': null
})
expect(launch.options.permissionMode).toBe(expected)
expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null })
})
// Manual is stored as an empty string, which owns the key and so beats the shipped default.
it.each([[''], ['--model Opus']])(
'leaves a Manual session prompting for args %s',
async (claude) => {
const launch = await resolverFor(record(), undefined, 'system', { claude })({
identity: IDENTITY
})
// The chat's own mode replaces Agent Permissions: a Yolo terminal setting grants a chat nothing.
it('ignores the bypass flag the agent Arguments carry', async () => {
const launch = await resolverFor(
record(),
undefined,
'system',
async () => true,
() => ['--dangerously-skip-permissions']
)({ identity: IDENTITY, permissionMode: 'ask' })
expect(launch.options.permissionMode).toBeUndefined()
expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null })
expect(launch.options.allowDangerouslySkipPermissions).toBeUndefined()
}
)
expect(launch.options.permissionMode).toBe('default')
expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null })
})
it('passes configured arguments on start without taking over permission or session flags', async () => {
const launch = await resolverFor(
@@ -343,7 +332,7 @@ describe('claude structured launch resolution', () => {
model: 'claude-sonnet-4-5',
'replay-user-messages': null
})
expect(launch.options.permissionMode).toBeUndefined()
expect(launch.options.permissionMode).toBe('default')
expect(launch.options.sessionId).toBe(launch.providerSessionId)
})
@@ -353,7 +342,6 @@ describe('claude structured launch resolution', () => {
record({ ...RESUMABLE, launchArgs: ['--model', 'stale'] }),
undefined,
'system',
{ claude: '' },
async () => true,
() => args
)
@@ -413,7 +401,6 @@ describe('claude structured launch resolution', () => {
record(),
undefined,
'system',
{ claude: '' },
async () => true,
undefined,
'/state/agent-session-attachments'
@@ -428,7 +415,6 @@ describe('claude structured launch resolution', () => {
record(),
undefined,
'system',
{ claude: '' },
async () => true,
() => ['--add-dir', '/extra'],
'/state/agent-session-attachments'
@@ -5,6 +5,7 @@ import type {
Options as ClaudeAgentSdkOptions,
PermissionMode
} from '@anthropic-ai/claude-agent-sdk'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provider-handle'
import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding'
import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
@@ -66,16 +67,20 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = {
extraArgs: { 'replay-user-messages': null }
}
/**
* Agent Permissions as query-start options.
*
* The owned CLI flag preserves the user-installed binary contract. The SDK's typed bypass option
* emits a newer allow flag that older Claude binaries reject before a structured session starts.
*/
const CLAUDE_PERMISSION_MODES = {
// Explicit, so a `defaultMode` in the user's Claude settings never runs other than the button says.
ask: 'default',
'accept-edits': 'acceptEdits',
auto: 'auto'
} as const satisfies Record<Exclude<AgentChatPermissionMode, 'full-access'>, PermissionMode>
/** Full access is the owned CLI flag: the SDK's typed bypass emits a flag older binaries reject. */
export function claudeStructuredPermissionOptions(
mode: PermissionMode
): Pick<ClaudeStructuredSdkOptions, 'extraArgs'> {
return mode === 'bypassPermissions' ? { extraArgs: { 'dangerously-skip-permissions': null } } : {}
mode: AgentChatPermissionMode
): Pick<ClaudeStructuredSdkOptions, 'permissionMode' | 'extraArgs'> {
return mode === 'full-access'
? { extraArgs: { 'dangerously-skip-permissions': null } }
: { permissionMode: CLAUDE_PERMISSION_MODES[mode] }
}
export type ClaudeStructuredLaunch = {
@@ -104,8 +109,6 @@ export type ClaudeStructuredLaunchPartDeps = {
/** Required, so every caller states which login a failed sign-in names rather than inherit a
* guess. Build it with claudeStructuredAuthPolicyForSettings. */
resolveAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
/** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */
resolvePermissionMode?: () => Promise<PermissionMode> | PermissionMode
/** The host's chat attachment store: files a client attached live there, outside the workspace,
* and the agent reads them without asking. */
attachmentDirectory?: string
@@ -197,9 +200,7 @@ export function claudeStructuredLaunchPart(
...(deps.attachmentDirectory ? [deps.attachmentDirectory] : []),
...(visuals ? [visuals.visuals.folder] : [])
]
const permission = claudeStructuredPermissionOptions(
(await deps.resolvePermissionMode?.()) ?? 'default'
)
const permission = claudeStructuredPermissionOptions(basis.permissionMode ?? 'ask')
// A start that failed before its first turn wrote no transcript, and `--resume` of an absent
// one exits; launch that id fresh instead. With a transcript, `--session-id` would collide.
const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null
@@ -48,6 +48,8 @@ export function listedModels(value: unknown): ListedModel[] {
const description = text(row.description)
const supportsFastMode =
typeof row.supportsFastMode === 'boolean' ? row.supportsFastMode : undefined
const supportsAutoMode =
typeof row.supportsAutoMode === 'boolean' ? row.supportsAutoMode : undefined
return [
{
id,
@@ -56,6 +58,7 @@ export function listedModels(value: unknown): ListedModel[] {
isDefault: resolvedModel !== null && resolvedModel === defaultResolvedModel,
efforts: listedEfforts(row),
...(supportsFastMode !== undefined ? { supportsFastMode } : {}),
...(supportsAutoMode !== undefined ? { supportsAutoMode } : {}),
resolvedModel
}
]
@@ -11,14 +11,10 @@ import type { ClaudeSession } from './claude-structured-session-state'
function ringSession(catalog: unknown[] = []) {
const session = sessionFor()
const setModel = vi.fn<ClaudeSession['connection']['setModel']>(async () => undefined)
const setPermissionMode = vi.fn<ClaudeSession['connection']['setPermissionMode']>(
async () => undefined
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: supplies every connection member a model or permission-mode write and its catalog read call.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: supplies every connection member a model write and its catalog read call.
session.connection = {
...session.connection,
setModel,
setPermissionMode,
supportedModels: async () => catalog
} as ClaudeSession['connection']
const state = journal()
@@ -54,25 +50,16 @@ describe('the context ring after a session option write', () => {
expect(s.respond('turn-b', 2_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 })
})
it('implies no window for a model the child refused or a mode the launch left out', async () => {
it('implies no window for a model the child refused', async () => {
const refused = ringSession()
refused.setModel.mockRejectedValueOnce(new ClaudeControlRequestError('set_model', 'refused'))
await expect(refused.write('model', 'opus[1m]')).rejects.toThrow()
expect(refused.respond('turn-a', 1_000)).toBeNull()
const skipped = ringSession()
adoptClaudeStructuredSpawnOptions(skipped.session, {
options: new Map([['model', 'sonnet[1m]']]),
skipped: ['permissionMode'],
fastModeAtStart: false
})
expect(skipped.respond('turn-a', 1_000)).toBeNull()
})
it('holds estimates after a permission-mode write even with a model written before it', async () => {
// The permission mode is a launch flag: a live write of it is refused like any unknown option.
it('refuses a live permission-mode write', async () => {
const s = ringSession()
await s.write('model', 'sonnet[1m]')
await s.write('permissionMode', 'plan')
expect(s.respond('turn-a', 1_000)).toBeNull()
await expect(s.write('permissionMode', 'plan')).rejects.toThrow(/permissionMode/)
})
})
+15 -20
View File
@@ -1,4 +1,4 @@
import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import {
@@ -13,7 +13,7 @@ import {
import type { ClaudeSession } from './claude-structured-session-state'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
const OPTION_ORDER = ['model', 'effort', 'fastMode', 'permissionMode'] as const
const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const
/**
* Efforts the settings readback cannot report. `max` applies for the rest of the
@@ -23,9 +23,6 @@ const OPTION_ORDER = ['model', 'effort', 'fastMode', 'permissionMode'] as const
*/
const UNREPORTED_EFFORTS: ReadonlySet<string> = new Set(['max'])
/** Writes that can move the main thread to another model or window: `opusplan` runs plan mode on Opus. */
const CONTEXT_WINDOW_KEYS: ReadonlySet<string> = new Set(['model', 'permissionMode'])
export function restoredClaudeStructuredSessionOptions(
options: Readonly<Record<string, string>> | undefined
): Map<string, string> {
@@ -38,7 +35,7 @@ export function restoredClaudeStructuredSessionOptions(
}
/** The keys `setClaudeStructuredOption` writes; a pick made at rest is checked against these. */
const CLAUDE_STRUCTURED_OPTION_KEYS = new Set(['model', 'permissionMode', 'effort', 'fastMode'])
const CLAUDE_STRUCTURED_OPTION_KEYS = new Set(['model', 'effort', 'fastMode'])
export function isClaudeStructuredOptionKey(key: string): boolean {
return CLAUDE_STRUCTURED_OPTION_KEYS.has(key)
@@ -74,17 +71,15 @@ export async function setClaudeStructuredOption(
const apply =
input.key === 'model'
? () => session.connection.setModel(input.value, { timeoutMs })
: input.key === 'permissionMode'
? () => session.connection.setPermissionMode(input.value as PermissionMode, { timeoutMs })
: input.key === 'effort'
? () =>
session.connection.applyFlagSettings(
{ effortLevel: input.value as EffortLevel },
{ timeoutMs }
)
: input.key === 'fastMode' && typeof fastMode === 'boolean'
? () => session.connection.applyFlagSettings({ fastMode }, { timeoutMs })
: null
: input.key === 'effort'
? () =>
session.connection.applyFlagSettings(
{ effortLevel: input.value as EffortLevel },
{ timeoutMs }
)
: input.key === 'fastMode' && typeof fastMode === 'boolean'
? () => session.connection.applyFlagSettings({ fastMode }, { timeoutMs })
: null
if (!apply) {
throw new AgentSessionOptionRejectedError(
`claude stream-json has no session option named ${input.key}`
@@ -148,8 +143,8 @@ export async function setClaudeStructuredOption(
const mutationSequence = ++session.optionMutationSequence
// Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it.
const held = session.options.get(input.key)
// Only a model write can stale the model report — an effort or permission-mode
// write does not change what the child is running. Leaving the stamp behind
// Only a model write can stale the model report — an effort or Fast write does
// not change what the child is running. Leaving the stamp behind
// would drop the session back to the written model and refuse, on the next
// effort write, a level the model actually running advertises.
if (modelWasConfirmed && input.key !== 'model') {
@@ -158,7 +153,7 @@ export async function setClaudeStructuredOption(
try {
await apply()
// Ahead of the fence checks: the child applied this write even if a newer one supersedes its bookkeeping.
if (CONTEXT_WINDOW_KEYS.has(input.key) && input.value !== held) {
if (input.key === 'model' && input.value !== held) {
session.translator?.modelMayHaveChanged()
}
if (input.key === 'model') {
@@ -1,45 +0,0 @@
import { describe, expect, it } from 'vitest'
import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode'
describe('claudeStructuredPermissionModeForSettings', () => {
// The three states the Agent Permissions toggle can leave behind. The untouched case is the
// common one and the easiest to get wrong: the toggle writes nothing until it is used, and the
// default Orca ships for the key it did not write is the bypass flag — which is what a terminal
// launch has always applied to an untouched profile.
it('bypasses when the user has never opened Agent settings', () => {
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: {} })).toBe(
'bypassPermissions'
)
expect(claudeStructuredPermissionModeForSettings({})).toBe('bypassPermissions')
expect(claudeStructuredPermissionModeForSettings(null)).toBe('bypassPermissions')
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { codex: '' } })).toBe(
'bypassPermissions'
)
})
it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => {
for (const claude of [
'--dangerously-skip-permissions',
'--dangerously-skip-permissions --model Opus',
'--model Opus --dangerously-skip-permissions'
]) {
expect(
claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude } }),
claude
).toBe('bypassPermissions')
}
})
// Manual is stored as an empty string, which owns the key and so beats the shipped default.
it('prompts when Manual cleared the flag', () => {
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '' } })).toBe(
'default'
)
})
it('prompts when the user replaced the flag with something else', () => {
expect(
claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '--model Opus' } })
).toBe('default')
})
})
@@ -1,26 +0,0 @@
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults'
/**
* The Agent Permissions setting as the SDK's own permission mode.
*
* Read per acquisition — like the environment overlay and the auth policy beside it — rather than
* latched into the session record: the setting is the one copy of this fact, so nothing can
* disagree with it and a failed restore cannot silently downgrade a session to prompting.
*
* Yolo still stores itself as the agent's bypass flag inside the launch arguments, which is also
* what a terminal launch acts on, so presence of that flag is the fact to read — resolved through
* the same default fallback the terminal uses, which is why an untouched profile bypasses. The
* rest of the arguments string is a terminal concern this path does not interpret.
*/
export function claudeStructuredPermissionModeForSettings(
settings:
| Partial<Pick<GlobalSettings, 'agentDefaultArgs' | 'terminalWindowsShell'>>
| null
| undefined
): PermissionMode {
return resolvedTuiAgentArgsBypassPermissions('claude', settings, process.platform)
? 'bypassPermissions'
: 'default'
}
@@ -7,7 +7,10 @@ import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
import * as claudeConnection from './claude-stream-json-connection'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
import {
CLAUDE_STRUCTURED_BASE_OPTIONS,
claudeStructuredPermissionOptions
} from './claude-structured-launch-resolution'
import { readClaudeInit } from './claude-structured-init-proof'
import {
realClaudeAuthenticated,
@@ -38,17 +41,25 @@ function realAdapter(
waitsForStartup = true
): ClaudeStructuredSessionAdapter {
const adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: command,
options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId },
cwd,
env,
claudeConfigDir,
providerSessionId,
resumeLeafUuid: null,
resumesTranscript: false,
continuesChain: false
}),
resolveLaunch: async ({ permissionMode }) => {
const permission = claudeStructuredPermissionOptions(permissionMode ?? 'ask')
return {
pathToClaudeCodeExecutable: command,
options: {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
...permission,
extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs },
sessionId: providerSessionId
},
cwd,
env,
claudeConfigDir,
providerSessionId,
resumeLeafUuid: null,
resumesTranscript: false,
continuesChain: false
}
},
onEvent: (event) => events.push(event),
...(onDispatchSettledLate ? { onDispatchSettledLate } : {}),
readProcessStartTime: async () => 1,
@@ -399,11 +410,15 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
120_000
)
// The contract a chat's first message depends on: saved options ride the launch, and the
// message is written as soon as the child is published, before the CLI answers initialize.
it.skipIf(!realClaudeAuthenticated)(
'runs a message written before initialize answers, under the saved options it was launched with',
async () => {
// The contract a chat's first message depends on: saved options and the chat's permission mode
// ride the launch, and the message is written as soon as the child is published, before the CLI
// answers initialize.
it.skipIf(!realClaudeAuthenticated).each([
['accept-edits', 'acceptEdits'],
['full-access', 'bypassPermissions']
] as const)(
'runs a message written before initialize answers, launched with saved options and %s',
async (permissionMode, cliPermissionMode) => {
const providerSessionId = randomUUID()
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
const events: ClaudeStructuredSessionEvent[] = []
@@ -424,7 +439,8 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
identity: identity(providerSessionId),
fence: 1,
spawnToken: 'real-cli-saved-options',
options: { model: 'sonnet', permissionMode: 'plan', effort: 'low' }
options: { model: 'sonnet', effort: 'low' },
permissionMode
})
const dispatched = await adapter.dispatch({
sessionId: 'real-cli-handshake',
@@ -453,7 +469,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
// The turn's own init names what the child was launched with.
expect(messages().find((m) => m.type === 'system' && m.subtype === 'init')).toMatchObject({
model: 'claude-sonnet-5',
permissionMode: 'plan'
permissionMode: cliPermissionMode
})
} finally {
await adapter.closeAll()
@@ -234,19 +234,18 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode'])
})
it.each([
['effort', { effort: 'retired-effort' }],
['permissionMode', { permissionMode: 'retired-mode' }]
] as const)('self-heals a persisted %s the CLI would refuse at launch', async (key, options) => {
// The chat's permission mode is the start's, never a saved option the CLI is handed.
it('self-heals a persisted effort the CLI would refuse, and launches the start mode', async () => {
const claude = fakeClaude()
const adapter = adapterFor(claude)
const resolveLaunch = vi.spyOn(adapter['deps'], 'resolveLaunch')
const options = { effort: 'retired-effort', permissionMode: 'full-access' }
const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }
await expect(
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options })
).resolves.toBeDefined()
expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key])
await adapter.acquire({ ...start, permissionMode: 'accept-edits' })
expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['effort'])
expect(claude.connections[0].launch.options).not.toHaveProperty('effort')
expect(claude.connections[0].launch.options).not.toHaveProperty('permissionMode')
expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits')
})
it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => {
@@ -1,10 +1,7 @@
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words'
import type { SubmissionRejectionFact } from '../../shared/agent-session-failure'
import type {
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type {
StructuredAgentSessionOptionsReportedEvent,
@@ -16,6 +13,7 @@ import type {
openClaudeStreamJsonConnection
} from './claude-stream-json-connection'
import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
import type { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition'
import type { ClaudeJournalTranslator } from './claude-journal-translator-contract'
import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { cancelProcessAcquisition } from '@orca/process-host/cancel-process-acquisition'
@@ -98,10 +96,7 @@ export type ClaudeLateDispatchOutcome =
export type ClaudeStructuredSessionAdapterDeps = {
/** The `/` surface of a chat whose Claude is not running. */
atRestCommands?: ClaudeAtRestCommandCatalog
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
spawnToken: string
}) => Promise<ClaudeStructuredLaunch>
resolveLaunch: (input: StructuredLaunchRequest) => Promise<ClaudeStructuredLaunch>
onEvent?: (event: ClaudeStructuredSessionEvent) => void
/** Direct settlement path for provider-proven late dispatch outcomes. */
onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void
@@ -19,28 +19,22 @@ function launched(
})
}
const BYPASS_LAUNCH: ClaudeStructuredSdkOptions = {
const FULL_ACCESS_LAUNCH: ClaudeStructuredSdkOptions = {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
extraArgs: {
...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs,
...claudeStructuredPermissionOptions('bypassPermissions').extraArgs
...claudeStructuredPermissionOptions('full-access').extraArgs
}
}
describe('a Claude chat launched with its saved options', () => {
it('passes the saved model, effort, Fast and permission mode as launch options', () => {
const spawn = launched({
model: 'opus',
effort: 'high',
fastMode: 'true',
permissionMode: 'plan'
})
it('passes the saved model, effort and Fast as launch options', () => {
const spawn = launched({ model: 'opus', effort: 'high', fastMode: 'true' })
expect(spawn.sdkOptions).toMatchObject({
model: 'opus',
effort: 'high',
settings: { fastMode: true },
permissionMode: 'plan',
// The base launch is kept whole.
includePartialMessages: true,
extraArgs: { 'replay-user-messages': null }
@@ -48,8 +42,7 @@ describe('a Claude chat launched with its saved options', () => {
expect(Object.fromEntries(spawn.options)).toEqual({
model: 'opus',
effort: 'high',
fastMode: 'true',
permissionMode: 'plan'
fastMode: 'true'
})
expect(spawn.skipped).toEqual([])
})
@@ -66,13 +59,12 @@ describe('a Claude chat launched with its saved options', () => {
}
)
it('leaves out an effort or permission mode no Claude can parse, and Fast it cannot decode', () => {
const spawn = launched({ effort: 'ludicrous', permissionMode: 'retired-mode', fastMode: 'yes' })
it('leaves out an effort no Claude can parse, and Fast it cannot decode', () => {
const spawn = launched({ effort: 'ludicrous', fastMode: 'yes' })
expect(spawn.sdkOptions).not.toHaveProperty('effort')
expect(spawn.sdkOptions).not.toHaveProperty('permissionMode')
expect(spawn.sdkOptions).not.toHaveProperty('settings')
expect(spawn.skipped).toEqual(['effort', 'fastMode', 'permissionMode'])
expect(spawn.skipped).toEqual(['effort', 'fastMode'])
})
it('keeps a saved Fast on for a new conversation, for its start to apply', () => {
@@ -128,38 +120,15 @@ describe('a Claude chat launched with its saved options', () => {
}
)
it('launches a saved bypass under an Agent Permissions bypass with the owned bypass flag', () => {
const spawn = launched({ permissionMode: 'bypassPermissions' }, { base: BYPASS_LAUNCH })
// The chat's permission mode is the launch's, never a saved option: the record keeps it whole.
it.each(['full-access', 'plan', 'bypassPermissions'])(
'leaves the launch permission alone with a saved permission mode %s',
(permissionMode) => {
const spawn = launched({ permissionMode }, { base: FULL_ACCESS_LAUNCH })
expect(spawn.sdkOptions.extraArgs).toEqual({
'replay-user-messages': null,
'dangerously-skip-permissions': null
})
expect(spawn.sdkOptions).not.toHaveProperty('permissionMode')
expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions')
})
it('never widens the Agent Permissions setting to a saved bypass', () => {
const spawn = launched({ permissionMode: 'bypassPermissions' })
expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null })
expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions')
expect(spawn.skipped).toEqual(['permissionMode'])
})
// Known limit: the allow flag that would keep bypass reachable is one older CLIs reject at start.
it('starts a saved narrower mode under an Agent Permissions bypass without any bypass flag', () => {
const spawn = launched({ permissionMode: 'acceptEdits' }, { base: BYPASS_LAUNCH })
expect(spawn.sdkOptions.permissionMode).toBe('acceptEdits')
expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions')
expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null })
})
it('starts a saved narrower mode without the allow flag when Agent Permissions prompts', () => {
const spawn = launched({ permissionMode: 'plan' })
expect(spawn.sdkOptions.permissionMode).toBe('plan')
expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions')
})
expect(spawn.sdkOptions).toEqual(FULL_ACCESS_LAUNCH)
expect(spawn.options.size).toBe(0)
expect(spawn.skipped).toEqual([])
}
)
})
@@ -2,7 +2,7 @@
// them, so its first message can be written at once instead of waiting for initialize to answer and
// a control request to apply each one.
import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
import type {
ClaudeStructuredLaunch,
@@ -18,33 +18,6 @@ const EFFORT_LEVELS: ReadonlySet<string> = new Set<EffortLevel>([
'xhigh',
'max'
])
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
'default',
'acceptEdits',
'bypassPermissions',
'plan',
'dontAsk',
'auto'
])
/** Whether `options` launch with the Agent Permissions bypass. */
function claudeStructuredOptionsBypassPermissions(options: ClaudeStructuredSdkOptions): boolean {
return options.extraArgs?.['dangerously-skip-permissions'] !== undefined
}
/** `options` launched in `mode` instead of the mode they carry; never more than they allow. */
function claudeStructuredOptionsWithPermissionMode(
options: ClaudeStructuredSdkOptions,
mode: PermissionMode
): ClaudeStructuredSdkOptions {
if (mode === 'bypassPermissions') {
return options
}
// Known limit: no switch back to bypass later; that needs the allow flag older CLIs reject.
const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {}
return { ...options, permissionMode: mode, extraArgs }
}
/** The saved value stands in for the agent Arguments' own flag for it: the chat's pick wins, and
* the CLI is never handed the flag twice. */
function withoutConfiguredFlag(
@@ -62,10 +35,6 @@ function isEffortLevel(value: string): value is EffortLevel {
return EFFORT_LEVELS.has(value)
}
function isPermissionMode(value: string): value is PermissionMode {
return PERMISSION_MODES.has(value)
}
export type ClaudeStructuredSpawnOptions = {
sdkOptions: ClaudeStructuredSdkOptions
/** The chat's options as the session holds them: what was launched, and a Fast the start applies. */
@@ -124,20 +93,6 @@ export function claudeStructuredSpawnOptions(input: {
}
}
}
const permissionMode = saved.get('permissionMode')
if (permissionMode !== undefined) {
// Bypass is the Agent Permissions setting's to grant; a saved pick never widens it.
if (
isPermissionMode(permissionMode) &&
(permissionMode !== 'bypassPermissions' ||
claudeStructuredOptionsBypassPermissions(input.launch.options))
) {
options.set('permissionMode', permissionMode)
sdkOptions = claudeStructuredOptionsWithPermissionMode(sdkOptions, permissionMode)
} else {
skipped.push('permissionMode')
}
}
return { sdkOptions, options, skipped, fastModeAtStart }
}
@@ -158,9 +113,4 @@ export function adoptClaudeStructuredSpawnOptions(
if (model !== undefined) {
session.translator?.modelWritten(model)
}
// The journal's window was measured under a mode this child did not take (`opusplan`'s plan
// mode runs Opus); a launch never leaves a model out.
if (spawn.skipped.includes('permissionMode')) {
session.translator?.modelMayHaveChanged()
}
}
@@ -80,7 +80,7 @@ async function until<T>(read: () => Promise<T | null> | T | null, what: string,
async function open(sessionId: string, cwd: string, resume: boolean, frames: Frame[]) {
const settings = process.env.ORCA_REAL_CLAUDE_SETTINGS
const permission = claudeStructuredPermissionOptions('bypassPermissions')
const permission = claudeStructuredPermissionOptions('full-access')
const connection = await openClaudeStreamJsonConnection(
{
pathToClaudeCodeExecutable: CLAUDE_BIN,
@@ -1,4 +1,7 @@
import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import {
createCodexStructuredLaunchResolver,
TEST_SPAWN_TOKEN
} from '../runtime/structured-agent-launch-composition.test-support'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -7,7 +10,6 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery'
@@ -55,7 +57,6 @@ function resolverFor(
value: AgentSessionRecord | null,
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
resolveRollout: () => Promise<string | null> = async () => null,
agentDefaultArgs: Record<string, string> = { codex: '' },
resolveLaunchArgs?: () => string[]
) {
return createCodexStructuredLaunchResolver({
@@ -63,8 +64,7 @@ function resolverFor(
resolveWorkspacePath,
resolveCommand: () => '/usr/local/bin/codex',
resolveRollout,
resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []),
resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs })
resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? [])
})
}
@@ -91,7 +91,6 @@ describe('codex structured launch resolution', () => {
}),
undefined,
undefined,
{ codex: '' },
() => args
)
await expect(resolve({ identity: IDENTITY })).rejects.toThrow(/Arguments/)
@@ -165,8 +164,12 @@ describe('codex structured launch resolution', () => {
cwd: '/repos/workspace-1',
codexHome: '/home/work/.codex',
resumeThreadId: null,
// Every launch now carries a posture; neither one is left for config.toml to decide.
permissionPolicy: { approvalPolicy: 'on-request', sandbox: 'workspace-write' },
// Every launch carries a posture; none is left for config.toml to decide. No mode asks.
permissionPolicy: {
approvalPolicy: 'on-request',
sandbox: 'workspace-write',
approvalsReviewer: 'user'
},
env: { CODEX_HOME: '/home/work/.codex' },
envToDelete: [NATIVE_CHAT_VISUALS_DIR_ENV]
})
@@ -251,38 +254,37 @@ describe('codex structured launch resolution', () => {
})
// app-server owns the permission posture on the thread RPC, not process flags.
it('resolves the bypass posture as app-server thread policy', async () => {
const launch = await resolverFor(record(), undefined, undefined, {
codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol'
})({ identity: IDENTITY })
it.each([
[
'ask',
{ approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'user' }
],
[
'auto',
{ approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'auto_review' }
],
[
'full-access',
{ approvalPolicy: 'never', sandbox: 'danger-full-access', approvalsReviewer: 'user' }
]
] as const)('opens a %s chat with its thread policy', async (permissionMode, policy) => {
const launch = await resolverFor(record())({
identity: IDENTITY,
spawnToken: TEST_SPAWN_TOKEN,
permissionMode
})
expect(launch.args).toEqual(['app-server'])
expect(launch.permissionPolicy).toEqual({
approvalPolicy: 'never',
sandbox: 'danger-full-access'
})
expect(launch.permissionPolicy).toEqual(policy)
})
it('bypasses approvals for a profile that never opened Agent settings', async () => {
const launch = await resolverFor(record(), undefined, undefined, {})({ identity: IDENTITY })
// The chat's mode replaces Agent Permissions: a Yolo terminal setting grants a chat nothing.
it('ignores the bypass flag the agent Arguments carry', async () => {
const launch = await resolverFor(record(), undefined, undefined, () => [
'--dangerously-bypass-approvals-and-sandbox'
])({ identity: IDENTITY, spawnToken: TEST_SPAWN_TOKEN, permissionMode: 'ask' })
expect(launch.args).toEqual(['app-server'])
expect(launch.permissionPolicy).toEqual({
approvalPolicy: 'never',
sandbox: 'danger-full-access'
})
})
// Stated, not omitted: app-server resolves an absent field through the mirrored config.toml,
// so a Manual session on a home carrying `approval_policy = "never"` never prompted at all.
it('states the approval posture under Manual', async () => {
const launch = await resolverFor(record())({ identity: IDENTITY })
expect(launch.args).toEqual(['app-server'])
expect(launch.permissionPolicy).toEqual({
approvalPolicy: 'on-request',
sandbox: 'workspace-write'
})
expect(launch.permissionPolicy).toMatchObject({ approvalPolicy: 'on-request' })
})
// A thread opened on the configured default and then given a turn on the saved model reads to
@@ -11,7 +11,7 @@ import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-
import { resolveCodexCommand } from '../codex-cli/command'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import {
@@ -24,9 +24,6 @@ export type CodexStructuredLaunchPartDeps = {
/** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
resolveRollout?: typeof resolvePinnedCodexRolloutProof
/** The user's Agent Permissions setting as thread policy, re-read per acquisition.
* States both postures outright — a resume inherits the last one for any field left absent. */
resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy
}
export type CodexStructuredInvocationDeps = Pick<
@@ -75,7 +72,6 @@ export function codexStructuredLaunchPart(
})
const environment = await basis.environment()
const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs())
const permissionPolicy = deps.resolvePermissionPolicy?.()
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
// A Codex record's chain holds only Codex handles; the launch admission refuses anything else.
const resumeThreadId = head?.handle.nativeId ?? null
@@ -95,7 +91,8 @@ export function codexStructuredLaunchPart(
// Only a thread this session created may still be one Codex never saved: a resumed,
// forked or adopted head names a conversation Codex held.
...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}),
...(permissionPolicy ? { permissionPolicy } : {}),
// States every posture outright: a resume inherits the last one for any field left absent.
permissionPolicy: codexStructuredPermissionPolicy(basis.permissionMode ?? 'ask'),
...(model ? { model } : {}),
...(visuals ? { visuals } : {}),
...(resumeThreadId
@@ -1,75 +1,42 @@
import { describe, expect, it } from 'vitest'
import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
const BYPASS = { approvalPolicy: 'never', sandbox: 'danger-full-access' }
// Approvals on, writes confined to the workspace. Verified against codex 0.153.4: both values are
// accepted on thread/start and thread/resume, and the reply echoes them back as the effective
// policy even when the home's config.toml asks for `never` / `danger-full-access`.
const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' }
const FULL_ACCESS = {
approvalPolicy: 'never',
sandbox: 'danger-full-access',
approvalsReviewer: 'user'
}
const ASK = { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'user' }
describe('codexStructuredPermissionPolicyForSettings', () => {
it('bypasses when the user has never opened Agent settings', () => {
expect(codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: {} })).toEqual(BYPASS)
expect(codexStructuredPermissionPolicyForSettings({})).toEqual(BYPASS)
expect(codexStructuredPermissionPolicyForSettings(null)).toEqual(BYPASS)
expect(
codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { claude: '' } })
).toEqual(BYPASS)
describe('codexStructuredPermissionPolicy', () => {
it('maps Full access to no prompts and no sandbox', () => {
expect(codexStructuredPermissionPolicy('full-access')).toEqual(FULL_ACCESS)
})
it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => {
for (const codex of [
'--dangerously-bypass-approvals-and-sandbox',
'--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol',
'--model gpt-5.6-sol --dangerously-bypass-approvals-and-sandbox'
]) {
expect(
codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex } }),
codex
).toEqual(BYPASS)
// The thread open spreads this, so an omitted field is absent, not reset: a thread resumed from
// Full access would keep `never`. Ask states every field, the human reviewer too.
it('states every Ask field, the human reviewer too', () => {
expect(codexStructuredPermissionPolicy('ask')).toEqual(ASK)
})
it('routes Approve for me to Codex auto-review under the Ask sandbox', () => {
expect(codexStructuredPermissionPolicy('auto')).toEqual({
...ASK,
approvalsReviewer: 'auto_review'
})
})
// A sandbox mode, never a `sandboxPolicy` object: Codex folds the user's own writable roots and
// network access into a mode, and a policy object would replace them.
it('names a sandbox mode for every mode', () => {
for (const mode of ['ask', 'auto', 'full-access'] as const) {
expect(codexStructuredPermissionPolicy(mode)).not.toHaveProperty('sandboxPolicy')
expect(typeof codexStructuredPermissionPolicy(mode).sandbox).toBe('string')
}
})
it('keeps quoted mentions and operands after -- in Manual', () => {
for (const codex of [
'--config "note=--dangerously-bypass-approvals-and-sandbox only as text"',
'-- --dangerously-bypass-approvals-and-sandbox'
]) {
expect(
codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex } }),
codex
).toEqual(MANUAL)
}
})
// Why an explicit policy rather than nothing: the thread-open path spreads this, so nothing
// means the fields are ABSENT, and absent is not a reset. A session flipped Yolo → Manual
// resumed with the Yolo thread's `approvalPolicy: never` still in force and escalated with no
// prompt. Manual has to say what it wants.
it('states the approval posture when Manual cleared the flag', () => {
expect(codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex: '' } })).toEqual(
MANUAL
)
})
it('never answers with an absent policy for either posture', () => {
for (const codex of ['', '--dangerously-bypass-approvals-and-sandbox', '--model gpt-5.6-sol']) {
expect(
codexStructuredPermissionPolicyForSettings({ agentDefaultArgs: { codex } }),
codex
).toBeDefined()
}
})
// The passthrough that used to carry these to app-server is gone on purpose; only the
// permission posture is derived, and nothing else from the field reaches argv.
it('carries nothing but the permission posture out of the arguments field', () => {
expect(
codexStructuredPermissionPolicyForSettings({
agentDefaultArgs: {
codex: '--profile review --add-dir /repo -c model_reasoning_effort=high'
}
})
).toEqual(MANUAL)
// Codex has no edits-only mode, so a stray one is the safer neighbour rather than more access.
it('asks for a mode Codex does not have', () => {
expect(codexStructuredPermissionPolicy('accept-edits')).toEqual(ASK)
})
})
@@ -1,49 +1,54 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import type { CodexApprovalsReviewer } from '../../shared/codex-subagent-reviewer'
export type CodexStructuredPermissionPolicy =
| { approvalPolicy: 'never'; sandbox: 'danger-full-access' }
| { approvalPolicy: 'on-request'; sandbox: 'workspace-write' }
| {
approvalPolicy: 'never'
sandbox: 'danger-full-access'
approvalsReviewer: CodexApprovalsReviewer
}
| {
approvalPolicy: 'on-request'
sandbox: 'workspace-write'
approvalsReviewer: CodexApprovalsReviewer
}
/** Yolo: no approval prompts, no sandbox. */
const BYPASS_POLICY = { approvalPolicy: 'never', sandbox: 'danger-full-access' } as const
/** Full access: no approval prompts, no sandbox. */
const FULL_ACCESS_POLICY = {
approvalPolicy: 'never',
sandbox: 'danger-full-access',
approvalsReviewer: 'user'
} as const
/**
* Manual: approvals on, writes confined to the workspace.
* Ask: approvals on, writes confined to the workspace.
*
* Why state it rather than omit it, which is what Manual used to do: app-server resolves an
* omitted field through the `config.toml` it was started with, and Orca mirrors the user's
* `~/.codex/config.toml` into the managed home it hands the app-server. Measured against codex
* 0.153.4 — with `approval_policy = "never"` in that file, a FRESH Manual thread comes up
* `never` + `dangerFullAccess` and never prompts. Manual was not a posture at all; it was
* "inherit whatever the config says". A resume additionally inherits the policy the thread was
* last started with, which is the path the bug was reported on; that half is not isolated here,
* because staging a real Yolo thread needs a live turn before codex writes the rollout.
* Why state it rather than omit it: app-server resolves an omitted field through the `config.toml`
* it was started with, which Orca mirrors from the user's own. Measured against codex 0.153.4 —
* with `approval_policy = "never"` in that file, a fresh thread that omits it comes up `never` +
* `dangerFullAccess` and never prompts, and a resume inherits the policy the thread last ran with.
*
* Why `workspace-write` and not codex's built-in `read-only`: read-only would override a
* deliberate `sandbox_mode = "workspace-write"` and make every file write in a Manual session
* need an approval it did not need before. This still resets Yolo's `danger-full-access`.
* deliberate `sandbox_mode = "workspace-write"` and make every file write need an approval it did
* not need before. A sandbox mode, never a `sandboxPolicy` object, so Codex still folds in the
* user's `[sandbox_workspace_write]` roots and network access.
*/
const MANUAL_POLICY = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const
const ASK_POLICY = {
approvalPolicy: 'on-request',
sandbox: 'workspace-write',
approvalsReviewer: 'user'
} as const
/** Approve for me: Ask's sandbox and approvals, reviewed by Codex's own reviewer agent. */
const AUTO_POLICY = { ...ASK_POLICY, approvalsReviewer: 'auto_review' } as const
/**
* The Agent Permissions setting as app-server thread policy.
*
* Derived per acquisition from the resolved launch arguments, never from the free-text Arguments
* field: app-server takes a narrower option set than the interactive CLI and the two are versioned
* apart, so the only thing read out of that field is the posture the toggle stores in it. An
* untouched profile resolves to the default Orca ships, which is the bypass flag.
*
* Always a policy, never `undefined`: both postures have to be said out loud, because the one
* that goes unsaid is the one a resume silently inherits from the other.
* A chat's permission mode as app-server thread policy, sent on `thread/start` and `thread/resume`.
* Every field is stated for every mode: one left unsaid is what a resume inherits from the last.
* Codex offers no edits-only mode, so anything else asks.
*/
export function codexStructuredPermissionPolicyForSettings(
settings:
| Partial<Pick<GlobalSettings, 'agentDefaultArgs' | 'terminalWindowsShell'>>
| null
| undefined
export function codexStructuredPermissionPolicy(
mode: AgentChatPermissionMode
): CodexStructuredPermissionPolicy {
return resolvedTuiAgentArgsBypassPermissions('codex', settings, process.platform)
? BYPASS_POLICY
: MANUAL_POLICY
return mode === 'full-access' ? FULL_ACCESS_POLICY : mode === 'auto' ? AUTO_POLICY : ASK_POLICY
}
@@ -9,9 +9,10 @@ import { resolveCodexCommand } from '../codex-cli/command'
import { NATIVE_CHAT_VISUALS_SKILL_NAME } from '../native-chat/native-chat-visuals-skill-location'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { withCodexVisualsThreadConfig } from './codex-structured-visuals'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1'
const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const
const MANUAL = codexStructuredPermissionPolicy('ask')
describe.skipIf(!enabled)('Codex real app-server chat visuals', () => {
it('lists the visuals skill and grants the chat folder beside the user writable roots', async () => {
@@ -114,7 +114,11 @@ export async function acquireCodexStructuredSession(input: {
}
acquisitions.assertCurrent(sessionId, attempt)
const launch = await deps
.resolveLaunch({ identity: acquireInput.identity, spawnToken: acquireInput.spawnToken })
.resolveLaunch({
identity: acquireInput.identity,
spawnToken: acquireInput.spawnToken,
...(acquireInput.permissionMode ? { permissionMode: acquireInput.permissionMode } : {})
})
.catch((error: unknown) => {
throw new AgentSessionPreSpawnError(error)
})
@@ -163,7 +163,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
expect(codex.connections[0].launch.cwd).toBe('/work/repo')
expect(codex.connections[0].calls[0]).toEqual({
method: 'thread/start',
params: { cwd: '/work/repo' }
// A chat with no mode asks, stated outright.
params: {
cwd: '/work/repo',
approvalPolicy: 'on-request',
sandbox: 'workspace-write',
approvalsReviewer: 'user'
}
})
expect(acquisition.process).toEqual({
hostId: 'host-1',
@@ -2,8 +2,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-
import type { AgentSessionAccountKind } from '../../shared/agent-session-availability'
import type {
AgentJournalAnsweredTurnIdentity,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { randomUUID } from 'node:crypto'
import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words'
@@ -23,6 +22,7 @@ import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import type { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition'
import type {
AgentModelCatalogSessionAccess,
AgentModelCatalogStore
@@ -90,10 +90,7 @@ export type CodexStructuredSessionEvent =
export type CodexStructuredSessionAdapterDeps = {
resolveAccountKind?: (home: string) => AgentSessionAccountKind | undefined
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
spawnToken: string
}) => Promise<CodexStructuredLaunch>
resolveLaunch: (input: StructuredLaunchRequest) => Promise<CodexStructuredLaunch>
onEvent?: (event: CodexStructuredSessionEvent) => void
/** Where bookkeeping a close or exit does after the child is gone reports a failure. */
logger?: StructuredAgentSessionLogger
@@ -5,7 +5,7 @@ import {
openCodexAppServerConnection,
type CodexAppServerConnection
} from './codex-app-server-connection'
import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { openCodexThread } from './codex-structured-thread-open'
function connectionFor(
@@ -20,10 +20,7 @@ describe('openCodexThread', () => {
thread: { id: method === 'thread/start' ? 'thread-created' : 'thread-existing' }
}))
const connection = connectionFor(request)
const permissionPolicy = {
approvalPolicy: 'never' as const,
sandbox: 'danger-full-access' as const
}
const permissionPolicy = codexStructuredPermissionPolicy('full-access')
await openCodexThread(
connection,
@@ -39,7 +36,12 @@ describe('openCodexThread', () => {
expect(request).toHaveBeenNthCalledWith(
1,
'thread/start',
{ cwd: '/workspace', approvalPolicy: 'never', sandbox: 'danger-full-access' },
{
cwd: '/workspace',
approvalPolicy: 'never',
sandbox: 'danger-full-access',
approvalsReviewer: 'user'
},
{ timeoutMs: 2_000 }
)
expect(request).toHaveBeenNthCalledWith(
@@ -50,23 +52,21 @@ describe('openCodexThread', () => {
cwd: '/workspace',
approvalPolicy: 'never',
sandbox: 'danger-full-access',
approvalsReviewer: 'user',
excludeTurns: true
},
{ timeoutMs: 2_000 }
)
})
// The regression this pins: Manual used to resolve to no policy at all, and the params below
// spread it — so neither field was sent and app-server fell back to the config.toml of the
// home Orca mirrors from the user's ~/.codex. With `approval_policy = "never"` there, a Manual
// session never prompted; a resume separately inherits the policy it was last started with.
it('sends Manual as an explicit policy on resume, not as absent fields', async () => {
// The regression this pins: an omitted policy falls back to the config.toml of the home Orca
// mirrors from the user's ~/.codex, and a resume inherits the policy it last ran with. With
// `approval_policy = "never"` there, an Ask chat would never prompt.
it('sends Ask as an explicit policy on resume, not as absent fields', async () => {
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
thread: { id: 'thread-existing' }
}))
const permissionPolicy = codexStructuredPermissionPolicyForSettings({
agentDefaultArgs: { codex: '' }
})
const permissionPolicy = codexStructuredPermissionPolicy('ask')
await openCodexThread(
connectionFor(request),
@@ -75,9 +75,16 @@ describe('openCodexThread', () => {
)
const params = request.mock.calls[0]?.[1] ?? {}
expect(params).toMatchObject({ approvalPolicy: 'on-request', sandbox: 'workspace-write' })
// Absence is the bug, so assert the keys are carried, not merely that they are not Yolo.
expect(Object.keys(params)).toEqual(expect.arrayContaining(['approvalPolicy', 'sandbox']))
expect(params).toMatchObject({
approvalPolicy: 'on-request',
sandbox: 'workspace-write',
approvalsReviewer: 'user'
})
// Absence is the bug, so assert the keys are carried, not merely that they are not Full access.
expect(Object.keys(params)).toEqual(
expect.arrayContaining(['approvalPolicy', 'sandbox', 'approvalsReviewer'])
)
expect(params).not.toHaveProperty('sandboxPolicy')
})
it('preserves an explicitly reported service tier, including Standard', async () => {
@@ -34,15 +34,9 @@ import {
// the turn to open and steers it.
/** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise
* become an arbitrary client-controlled `turn/start` parameter. Permission posture is owned by
* Agent Permissions and applied when the thread opens. */
const CODEX_TURN_OPTION_KEYS = new Set([
'model',
'effort',
'approvalsReviewer',
'personality',
'serviceTier'
])
* become an arbitrary client-controlled `turn/start` parameter. Permission posture is the chat's
* permission mode, stated when the thread opens: a saved reviewer here would override it. */
const CODEX_TURN_OPTION_KEYS = new Set(['model', 'effort', 'personality', 'serviceTier'])
export function isCodexTurnOptionKey(key: string): boolean {
return CODEX_TURN_OPTION_KEYS.has(key)
@@ -18,13 +18,14 @@ import {
CODEX_VISUALS_SETUP_BUDGET_MS,
prepareCodexThreadForVisuals
} from './codex-structured-visuals'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
const VISUALS: NativeChatVisualsLaunch = {
folder: '/state/native-chat-visuals/abc',
skill: { pluginDir: '/app/native-chat-visuals', skillsRoot: '/app/native-chat-visuals/skills' }
}
const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const
const YOLO = { approvalPolicy: 'never', sandbox: 'danger-full-access' } as const
const MANUAL = codexStructuredPermissionPolicy('ask')
const YOLO = codexStructuredPermissionPolicy('full-access')
type Call = { method: string; params?: Record<string, unknown>; timeoutMs?: number }
@@ -188,7 +189,6 @@ async function acquired(
resolveCommand: () => 'codex',
resolveLaunchArgs: () => [],
resolveRollout: async () => null,
resolvePermissionPolicy: () => MANUAL,
prepareVisuals: async () => launch.visuals ?? null
})
})
@@ -71,6 +71,9 @@ function parseModel(value: unknown): AgentSessionModelOption | null {
...(typeof row.supportsFastMode === 'boolean'
? { supportsFastMode: row.supportsFastMode }
: {}),
...(typeof row.supportsAutoMode === 'boolean'
? { supportsAutoMode: row.supportsAutoMode }
: {}),
...(serviceTiers ? { serviceTiers } : {})
}
}
@@ -68,11 +68,13 @@ export async function acquireOwner(
} catch (error) {
throw new AgentSessionPreSpawnError(error)
}
const permissionMode = await input.launchPermissionMode?.(record)
const attempt = mintStructuredAgentSessionStartupAttempt({
record,
identity: journalIdentityFor(record, input.params),
// Retries must recover the original reservation, not mint a second child.
spawnToken,
...(permissionMode ? { permissionMode } : {}),
...(input.eventSink ? { events: input.eventSink } : {}),
...(input.acquireSignal ? { signal: input.acquireSignal } : {}),
optionRevision: input.optionRevision
@@ -17,6 +17,7 @@ import type {
} from '../../../shared/agent-session-wire'
import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
import {
admitAttachOrRefuse,
attachJournal,
@@ -75,6 +76,10 @@ export type AttachFlowInput = {
onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void
/** The conversation's option revision, which the child's reports are stamped with. */
optionRevision: () => number
/** The permission mode a start of this record launches with; absent or undefined for none. */
launchPermissionMode?: (
record: AgentSessionRecord
) => Promise<AgentChatPermissionMode | undefined>
/** Publishes the journal before clients can send against the new owner. `acquiredOwner` is
* true only when this attach spawned the provider child, so a re-attach to a live one is not
* mistaken for a cold acquire. */
@@ -32,6 +32,7 @@ import {
} from './structured-agent-session-provider-child'
import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt'
import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract'
@@ -205,6 +206,8 @@ async function runAttachUnderAbort(
return startupAttempts.track(sessionId, startup)
},
optionRevision: () => context.runtimeState.optionRevisions.current(sessionId),
launchPermissionMode: async (record) =>
(await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined,
...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}),
openConversation: async (record) => {
const conversation = await context.openConversation(record.sessionId, {
@@ -222,6 +225,10 @@ async function runAttachUnderAbort(
const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId)
const current = context.sessions.get(sessionId)?.child ?? null
const startedFor = acquiredOwner ? options.startedFor : current?.startedFor
// A re-attach to a live child keeps the mode that child was launched with.
const permissionMode = acquiredOwner
? attempt.startup?.permissionMode
: current?.permissionMode
// A re-attach to a live child keeps the sink that child already writes through.
const eventSink = acquiredOwner
? attemptSink
@@ -248,7 +255,8 @@ async function runAttachUnderAbort(
fence,
// A re-attach to a live child keeps what that child already proved, and its cause.
phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready'),
...(startedFor === undefined ? {} : { startedFor })
...(startedFor === undefined ? {} : { startedFor }),
...(permissionMode === undefined ? {} : { permissionMode })
}
}
await recoverStructuredRewind(
@@ -22,7 +22,8 @@ import type {
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
import { recoverStructuredRewind } from './structured-rewind-recovery'
import { retireSignedOutStructuredAgentSessionChild } from './structured-agent-session-signed-out-child'
import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
export type StructuredAgentSessionConversationDelivery = {
loop: StructuredAgentSessionDeliveryLoop
@@ -57,8 +58,8 @@ export function createStructuredAgentSessionConversationDelivery(input: {
sessionId: string,
startedFor: string
) => Promise<StructuredAgentSessionResumeOutcome>
/** Stops a child that reported it is not signed in, so the start after it reads a new login. */
stopSignedOutAgent: (sessionId: string) => Promise<void>
/** Stops a child the chat outgrew, so the start after it reads a new login or permission mode. */
stopOutgrownAgent: (sessionId: string) => Promise<void>
clientDelivery: Pick<
StructuredAgentSessionClientDelivery,
'publishRestored' | 'readChildWork' | 'readStopping' | 'publishStatus'
@@ -76,7 +77,7 @@ export function createStructuredAgentSessionConversationDelivery(input: {
const session = input.clientDelivery.readStopping(sessionId)
? undefined
: sessions.get(sessionId)
await retireSignedOutStructuredAgentSessionChild(sessionId, session, {
await retireOutgrownStructuredAgentSessionChild(sessionId, session, {
work: {
childWork: () => input.clientDelivery.readChildWork(sessionId),
hasOpenDispatch: () => {
@@ -86,7 +87,11 @@ export function createStructuredAgentSessionConversationDelivery(input: {
providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true
},
startUnavailable: () => deps.adapter.startUnavailable?.(sessionId),
stopAgent: input.stopSignedOutAgent,
launchPermission: async () => {
const record = deps.store.getRecord(sessionId)
return record ? structuredAgentSessionLaunchPermission(deps, record) : null
},
stopAgent: input.stopOutgrownAgent,
logger: deps.logger
})
return input.ensureProviderChild(sessionId, startedFor)
@@ -30,6 +30,7 @@ import {
sendPreparation,
structuredAgentSessionSendBlock
} from './structured-agent-session-send-preparation'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode'
import {
cancelPlan,
promptPlan,
@@ -176,10 +177,14 @@ export async function setStructuredAgentSessionOption(
caller: StructuredAgentSessionCaller,
params: { envelope: AgentSessionMutationEnvelope; key: string; value: string }
): Promise<AgentSessionMutationResult<AgentSessionOptionResult>> {
// Outside the queue: a pick made while the provider starts then queues behind what its start persists.
await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId)
// The permission mode is only ever the record's: the next start launches with it.
const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
if (!recordOnly) {
// Outside the queue: a pick made while the provider starts then queues behind what its start persists.
await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId)
}
const plan = setOptionPlan(params)
const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child
const atRest = () => recordOnly || !context.sessions.get(params.envelope.sessionId)?.child
return mutateStructuredAgentSession(
context,
caller,
@@ -26,6 +26,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
import type { StructuredAgentRegistry } from './structured-agent-registry'
import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract'
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
export type StructuredAgentSessionCaller = { callerKey: string }
@@ -73,6 +74,8 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider
/** The queued message whose delivery started this child, fixed when the start is made; absent
* for any other start. In memory only: it tells a restart offer its own start from another. */
readonly startedFor?: string
/** The permission mode it was launched with, in memory only; absent for an agent with none. */
readonly permissionMode?: AgentChatPermissionMode
close?: StructuredAgentSessionChildClose
}
@@ -124,7 +124,7 @@ export class StructuredAgentSessionHost {
ensureProviderChild: (sessionId, startedFor) =>
agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor),
// Put to rest like an idle agent: the stop writes nothing, and the next start is new.
stopSignedOutAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }),
stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }),
clientDelivery: this.clientDelivery
})
this.restore = reveal.createStructuredAgentSessionHostRestore(deps, {
@@ -0,0 +1,110 @@
// The mode a start launches with: Claude's Approve for me needs its model listed for it in the saved
// catalog; anything the catalog cannot confirm launches Ask.
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire'
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
function record(provider: string, options: Record<string, string> | undefined): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent and options.
return { sessionId: 'session-1', provider, ...(options ? { options } : {}) } as AgentSessionRecord
}
const listing = (
models: { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[]
): AgentSessionModelCatalogResult => ({
origin: 'probe',
fetchedAt: 1,
models: models.map((model) => ({
label: model.id,
efforts: [],
isDefault: false,
...model
}))
})
function launchPermission(
provider: string,
options: Record<string, string> | undefined,
answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' }
) {
const read = vi.fn(async () => {
if (answer instanceof Error) {
throw answer
}
return answer
})
const modelCatalog = {
read,
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
providerStarted: vi.fn()
}
return {
read,
result: structuredAgentSessionLaunchPermission(
{ agents: claudeAndCodexDeclared(), modelCatalog },
record(provider, options)
)
}
}
describe('the permission mode a chat starts with', () => {
it('launches a saved mode that needs no model as it is, without reading the catalog', async () => {
const claude = launchPermission('claude', { permissionMode: 'accept-edits' })
await expect(claude.result).resolves.toBe('accept-edits')
expect(claude.read).not.toHaveBeenCalled()
const codex = launchPermission('codex', { permissionMode: 'auto' })
await expect(codex.result).resolves.toBe('auto')
expect(codex.read).not.toHaveBeenCalled()
})
it('asks for a chat saved before modes existed', async () => {
await expect(launchPermission('claude', undefined).result).resolves.toBe('ask')
})
it('runs Claude auto on a model the saved catalog lists as supporting it', async () => {
const supported = launchPermission(
'claude',
{ permissionMode: 'auto', model: 'opus' },
listing([{ id: 'opus', supportsAutoMode: true }])
)
await expect(supported.result).resolves.toBe('auto')
expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true }))
})
it('matches a chat with no model to the listed default', async () => {
await expect(
launchPermission(
'claude',
{ permissionMode: 'auto' },
listing([
{ id: 'sonnet', isDefault: true, supportsAutoMode: false },
{ id: 'opus', supportsAutoMode: true }
])
).result
).resolves.toBe('ask')
})
it('asks when the catalog cannot confirm the model runs auto', async () => {
for (const answer of [
{ origin: 'unknown' } as const,
listing([{ id: 'opus' }]),
listing([{ id: 'sonnet', supportsAutoMode: true }]),
new Error('catalog unavailable')
]) {
await expect(
launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result
).resolves.toBe('ask')
}
})
it('has none for an agent with no modes', async () => {
await expect(launchPermission('grok', { permissionMode: 'full-access' }).result).resolves.toBe(
null
)
})
})
@@ -0,0 +1,50 @@
// The one place that decides a chat's permission mode: what a start launches, a restart compares, a read shows.
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
agentChatPermissionModes,
chatModelSupportsAutoMode,
chatPermissionModeNeedsModelSupport,
effectiveChatPermissionMode,
type AgentChatPermissionMode
} from '../../../shared/agent-chat-permission-mode'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
/** Saved catalog only, so a start never waits on a listing; for the chat's own folder, as its
* options read names its default. */
async function listedAutoModeSupport(
catalog: StructuredAgentSessionHostDeps['modelCatalog'],
record: AgentSessionRecord
): Promise<boolean> {
const listing = await catalog
?.read({
agent: record.provider,
sessionId: record.sessionId,
savedOnly: true,
inSessionWorkspace: true
})
.catch(() => null)
return (
listing !== null &&
listing !== undefined &&
listing.origin !== 'unknown' &&
chatModelSupportsAutoMode(listing.models, record.options?.model)
)
}
/** Null for an agent whose chats offer no modes, or one this runtime did not register. */
export async function structuredAgentSessionLaunchPermission(
deps: Pick<StructuredAgentSessionHostDeps, 'agents' | 'modelCatalog'>,
record: AgentSessionRecord
): Promise<AgentChatPermissionMode | null> {
const support = agentChatPermissionModes(record.provider)
if (!support || !deps.agents.definition(record.provider)) {
return null
}
const intent = record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID]
const autoSupported =
chatPermissionModeNeedsModelSupport(support, intent) &&
(await listedAutoModeSupport(deps.modelCatalog, record))
return effectiveChatPermissionMode(support, intent, autoSupported)
}
@@ -62,12 +62,14 @@ describe('options at rest', () => {
expect(result.models).toEqual([])
expect(result.current).not.toHaveProperty('model')
record.options = { model: 'later-model', effort: 'off' }
// Of these, only Codex's chats state a permission mode.
expect(
(await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }))
.current
).toEqual({
model: 'later-model',
effort: 'off'
effort: 'off',
...(provider === 'codex' ? { permissionMode: 'ask' } : {})
})
}
)
@@ -13,6 +13,10 @@ import {
} from '../../../shared/agent-session-wire'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec'
import {
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
agentChatPermissionModes
} from '../../../shared/agent-chat-permission-mode'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure'
import type { StructuredAgentDefinition } from './structured-agent-definition'
@@ -20,6 +24,7 @@ import type { StructuredAgentRegistry } from './structured-agent-registry'
import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
@@ -33,14 +38,29 @@ export const IN_PROCESS_OPTIONS_READER: StructuredAgentSessionOptionsReader = {
readsWithoutModel: true
}
/** Whether the agent takes this pick: a permission mode it offers, or an option key it names. */
function acceptsOptionPick(
agents: Pick<StructuredAgentRegistry, 'definition'>,
provider: string,
key: string,
value: string
): boolean {
const definition = agents.definition(provider)
return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
? definition !== null &&
agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true
: definition?.restingOptions.acceptsKey(key) === true
}
/** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */
export function structuredAgentSessionOptionOverridesRefusal(
agents: Pick<StructuredAgentRegistry, 'definition'>,
provider: string,
options: Readonly<Record<string, string>>
) {
const rules = agents.definition(provider)?.restingOptions
const key = Object.keys(options).find((key) => !rules?.acceptsKey(key))
const key = Object.keys(options).find(
(key) => !acceptsOptionPick(agents, provider, key, options[key] ?? '')
)
return key === undefined
? null
: refuse(
@@ -134,7 +154,7 @@ export async function recordStructuredAgentSessionOptionIntent(
input: { key: string; value: string }
): Promise<TurnOutcome<AgentSessionOptionResult>> {
const record = deps.store.getRecord(ctx.sessionId)
if (!record || !restingOptionRules(deps.agents, record)?.acceptsKey(input.key)) {
if (!record || !acceptsOptionPick(deps.agents, record.provider, input.key, input.value)) {
return {
ok: false,
refusal: refuse(
@@ -204,13 +224,19 @@ export async function readStructuredAgentSessionOptions(
}
// Re-acquired after the reads above: the handle they saw may have closed and reopened since.
const session = await context.conversation(sessionId)
const phase = store.getRecord(sessionId)?.rewind?.phase
const record = store.getRecord(sessionId)
const phase = record?.rewind?.phase
const agent = session.params.provider
const capabilities = agents.capabilities(agent)
const floor = session.journal.context.floor()
const contextFloor = floor ? { contextFloor: floor } : {}
// What a start launches now, live or at rest: a child never reports it.
const permissionMode = record
? await structuredAgentSessionLaunchPermission(context.deps, record)
: null
return {
...options,
...(permissionMode ? { current: { ...options.current, permissionMode } } : {}),
rewind:
phase === 'prepared' || phase === 'provider-succeeded'
? { supported: false, reason: 'outcome-unknown' }
@@ -1,5 +1,6 @@
// A child that reported it is not signed in is replaced before the next send, so a sign-in made
// since reaches a new child; an agent that read its login once at start would fail again.
// A child the chat outgrew is replaced before the next send: one that reported it is not signed in,
// so a sign-in made since reaches a new child, and one launched with a permission mode the chat no
// longer has, so the new mode reaches a new child.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
@@ -14,9 +15,13 @@ import {
type RestTestRig
} from './structured-agent-session-rest-test-rig'
import {
retireSignedOutStructuredAgentSessionChild,
retireOutgrownStructuredAgentSessionChild,
structuredAgentSessionChildReportedSignedOut
} from './structured-agent-session-signed-out-child'
} from './structured-agent-session-outgrown-child'
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types'
import type {
AgentJournalItemBody,
@@ -185,7 +190,7 @@ describe('structuredAgentSessionChildReportedSignedOut', () => {
})
})
describe('retireSignedOutStructuredAgentSessionChild', () => {
describe('retireOutgrownStructuredAgentSessionChild', () => {
const liveChildWork: AgentChildWorkView = {
id: 'subagent-1',
kind: 'agent',
@@ -205,11 +210,13 @@ describe('retireSignedOutStructuredAgentSessionChild', () => {
const retire = async (
input: Parameters<typeof conversation>[0],
work: Partial<typeof idle> = {},
stopAgent = vi.fn(async () => undefined)
stopAgent = vi.fn(async () => undefined),
launchPermission?: () => Promise<AgentChatPermissionMode | null>
) => {
const warn = vi.fn()
await retireSignedOutStructuredAgentSessionChild('session', conversation(input), {
await retireOutgrownStructuredAgentSessionChild('session', conversation(input), {
work: { ...idle, ...work },
...(launchPermission ? { launchPermission } : {}),
stopAgent,
logger: { warn, error: vi.fn() }
})
@@ -261,8 +268,132 @@ describe('retireSignedOutStructuredAgentSessionChild', () => {
})
)
expect(warn).toHaveBeenCalledWith(
'replacing a signed-out agent failed',
'replacing an outgrown agent failed',
expect.objectContaining({ sessionId: 'session' })
)
})
describe('a child launched with another permission mode', () => {
const now = (mode: AgentChatPermissionMode) => vi.fn(async () => mode)
const askChild = { child: childAt({ permissionMode: 'ask' }) }
it('is stopped once, and one launched with the current mode is kept', async () => {
expect(
(await retire(askChild, {}, undefined, now('full-access'))).stopAgent
).toHaveBeenCalledWith('session')
expect((await retire(askChild, {}, undefined, now('ask'))).stopAgent).not.toHaveBeenCalled()
})
it('is kept while it owes work, as a signed-out one is', async () => {
for (const kept of [
await retire({ ...askChild, activeTurnId: 'turn-1' }, {}, undefined, now('auto')),
await retire(askChild, { childWork: () => [liveChildWork] }, undefined, now('auto')),
await retire({ ...askChild, items: [pendingPrompt] }, {}, undefined, now('auto'))
]) {
expect(kept.stopAgent).not.toHaveBeenCalled()
}
})
it('is kept while starting, or for an agent with no modes', async () => {
const starting = { child: childAt({ permissionMode: 'ask', phase: 'starting' }) }
expect((await retire(starting, {}, undefined, now('auto'))).stopAgent).not.toHaveBeenCalled()
expect(
(await retire(askChild, {}, undefined, async () => null)).stopAgent
).not.toHaveBeenCalled()
})
// The check reads the same mode a start launches with, so each start ends the restarts.
describe('against the mode a start launches with', () => {
// The saved catalog the probe keeps from `claude` 2.1.280: the default row folded into the
// row it resolves to, every row but haiku listed as running auto.
const listed = [
{ id: 'opus[1m]', isDefault: true, supportsAutoMode: true },
{ id: 'sonnet', isDefault: false, supportsAutoMode: true },
{ id: 'haiku', isDefault: false }
]
const catalog = (rows: typeof listed) => ({
read: vi.fn(async () => ({
origin: 'probe' as const,
fetchedAt: 1,
models: rows.map((row) => ({ ...row, label: row.id, efforts: [] }))
})),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
providerStarted: vi.fn()
})
const launch = (options: Record<string, string>, rows = listed) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent and options.
const record = { sessionId: 'session', provider: 'claude', options } as AgentSessionRecord
return () =>
structuredAgentSessionLaunchPermission(
{ agents: claudeAndCodexDeclared(), modelCatalog: catalog(rows) },
record
)
}
const stoppedAfterLaunch = async (
launched: AgentChatPermissionMode,
current: () => Promise<AgentChatPermissionMode | null>
) =>
(await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current))
.stopAgent.mock.calls.length > 0
it('restarts Full access or Accept edits into Ask when auto is not confirmed', async () => {
const unconfirmed = [
launch({ model: 'haiku', permissionMode: 'auto' }),
launch({ permissionMode: 'auto' }, [{ id: 'opus[1m]', isDefault: true }])
]
for (const autoPick of unconfirmed) {
await expect(autoPick()).resolves.toBe('ask')
expect(await stoppedAfterLaunch('full-access', autoPick)).toBe(true)
expect(await stoppedAfterLaunch('accept-edits', autoPick)).toBe(true)
expect(await stoppedAfterLaunch('ask', autoPick)).toBe(false)
}
})
it('never restarts a child it just started, from an alias or a resolved id', async () => {
const records: Record<string, string>[] = [
{ permissionMode: 'auto' },
{ model: 'default', permissionMode: 'auto' },
{ model: 'opus[1m]', permissionMode: 'auto' },
{ model: 'sonnet', permissionMode: 'auto' },
{ model: 'haiku', permissionMode: 'auto' },
{ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' }
]
const runsAuto = [true, true, true, true, false, false]
for (const [index, options] of records.entries()) {
const current = launch(options)
const launched = await current()
expect(launched).toBe(runsAuto[index] ? 'auto' : 'ask')
expect(await stoppedAfterLaunch(launched!, current)).toBe(false)
}
})
// A resolved id launches Ask; the start reports the listed alias, so one restart reaches auto.
it('settles after one restart once a resolved id is reported as its alias', async () => {
const first = await launch({ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' })()
const reported = launch({ model: 'opus[1m]', permissionMode: 'auto' })
expect(await stoppedAfterLaunch(first!, reported)).toBe(true)
expect(await stoppedAfterLaunch((await reported())!, reported)).toBe(false)
})
})
it('never reads the mode for a child launched with none', async () => {
const current = now('full-access')
expect((await retire({}, {}, undefined, current)).stopAgent).not.toHaveBeenCalled()
expect(current).not.toHaveBeenCalled()
})
})
})
const pendingPrompt: Row = {
itemId: 'approval-1',
fence: 2,
body: {
kind: 'approval',
title: 'Run a command',
detail: null,
options: [],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
}
@@ -1,5 +1,5 @@
// A child that reported its agent is not signed in is replaced before the next send. Some agents
// read their saved login only when they start, so a sign-in made since reaches a new child only.
// A child the chat outgrew (signed out, or launched with another permission mode) is replaced before
// the next send: agents read their login and permission mode only when they start.
import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability'
import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure'
@@ -13,9 +13,10 @@ import {
type StructuredAgentSessionChildWorkReads
} from './structured-agent-session-idle-sweep'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
/** What the check reads of a conversation; every read skips building a snapshot. */
type SignedOutReading = Pick<StructuredAgentSessionHostSession, 'child'> & {
type OutgrownChildReading = Pick<StructuredAgentSessionHostSession, 'child'> & {
journal: Pick<
AgentSessionJournal,
'visitItems' | 'visitItemsWithLinkage' | 'itemFence' | 'activeTurnId' | 'newestTurn'
@@ -31,7 +32,7 @@ const signedOut = (failure: unknown): boolean =>
* wrote. Each child holds its own fence, so matching it scopes both to this child; a new one has
* nothing. `startUnavailable` is the adapter's read of that child's start. */
export function structuredAgentSessionChildReportedSignedOut(
session: SignedOutReading,
session: OutgrownChildReading,
startUnavailable?: AgentSessionUnavailable
): boolean {
const { child, journal } = session
@@ -78,30 +79,47 @@ function owesWakeUp(
)
}
/** Loop-free: a start launches with the same `current` this compares against. */
async function launchedWithOtherPermissionMode(
child: OutgrownChildReading['child'],
current: (() => Promise<AgentChatPermissionMode | null>) | undefined
): Promise<boolean> {
if (!child || child.phase === 'starting' || child.close || !child.permissionMode || !current) {
return false
}
const mode = await current()
return mode !== null && mode !== child.permissionMode
}
/** For a caller inside the session's serialize, before it hands the next send to the child. Work
* the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails leaves
* the close begun, which the start joins. */
export async function retireSignedOutStructuredAgentSessionChild(
export async function retireOutgrownStructuredAgentSessionChild(
sessionId: string,
session: SignedOutReading | undefined,
session: OutgrownChildReading | undefined,
deps: {
work: StructuredAgentSessionChildWorkReads
startUnavailable?: () => AgentSessionUnavailable | undefined
/** The mode a start of the chat would launch with now. */
launchPermission?: () => Promise<AgentChatPermissionMode | null>
stopAgent: (sessionId: string) => Promise<void>
logger: StructuredAgentSessionLogger
}
): Promise<void> {
if (
!session ||
!structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) ||
!(
structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) ||
(await launchedWithOtherPermissionMode(session.child, deps.launchPermission))
) ||
structuredAgentSessionChildHasOpenWork(session.journal, deps.work) ||
owesWakeUp(session.journal, deps.work.childWork())
) {
return
}
await deps.stopAgent(sessionId).catch((error: unknown) =>
deps.logger.warn('replacing a signed-out agent failed', {
scope: 'signed-out-child',
deps.logger.warn('replacing an outgrown agent failed', {
scope: 'outgrown-child',
sessionId,
error
})
@@ -0,0 +1,105 @@
// A chat's permission mode is the record's: a pick writes only the record, even with the agent
// running, and the next send after a change goes to an agent started with the new mode.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import {
createRestTestRig,
foundRestTestChat,
REST_TEST_CALLER as CALLER,
REST_TEST_SESSION as SESSION,
restTestSend,
type RestTestRig
} from './structured-agent-session-rest-test-rig'
import { hostTestOperationId } from './structured-agent-session-host-test-data'
describe('a chat permission mode', () => {
let rig: RestTestRig
beforeEach(async () => {
rig = await createRestTestRig()
})
afterEach(async () => {
await rig.dispose()
})
const fence = () => rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1
const pick = (value: string) => {
const fields = { key: 'permissionMode', value }
return rig.host.setOption(CALLER, {
envelope: {
sessionId: SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: fence(),
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.setOption',
sessionId: SESSION,
fields
})
},
...fields
})
}
it('starts a chat saved before modes existed as Ask, and reads it back as Ask', async () => {
await foundRestTestChat(rig)
expect(rig.adapter.acquire).toHaveBeenCalledWith(
expect.objectContaining({ permissionMode: 'ask' })
)
expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('ask')
})
it('writes a pick to the record only, with the agent running', async () => {
await foundRestTestChat(rig)
const setOption = vi.spyOn(rig.host.deps.adapter, 'setOption')
expect(await pick('full-access')).toMatchObject({
ok: true,
value: { options: { permissionMode: 'full-access' } }
})
expect(setOption).not.toHaveBeenCalled()
expect(rig.store.getRecord(SESSION)?.options?.permissionMode).toBe('full-access')
expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('full-access')
expect(rig.host.readStatusSummary(SESSION)?.permissionMode).toBe('full-access')
})
it('refuses a mode the agent does not offer', async () => {
await foundRestTestChat(rig)
// Codex has no edits-only mode.
expect(await pick('accept-edits')).toMatchObject({ ok: false })
expect(await pick('bypassPermissions')).toMatchObject({ ok: false })
expect(rig.store.getRecord(SESSION)?.options?.permissionMode).toBeUndefined()
})
it('starts a new agent with the new mode for the next send, and keeps it after', async () => {
await foundRestTestChat(rig)
rig.adapter.closeSession.mockClear()
await pick('auto')
expect((await rig.host.send(CALLER, restTestSend('after the change', fence()))).ok).toBe(true)
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2))
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
expect(rig.adapter.acquire).toHaveBeenLastCalledWith(
expect.objectContaining({ permissionMode: 'auto' })
)
expect((await rig.host.send(CALLER, restTestSend('and again', fence()))).ok).toBe(true)
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3))
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
})
it('keeps the running agent for a send while its reply runs', async () => {
await foundRestTestChat(rig)
rig.adapter.closeSession.mockClear()
const holds = rig.adapter.holdsDispatch.mockReturnValue(true)
await pick('full-access')
expect((await rig.host.send(CALLER, restTestSend('mid-reply', fence()))).ok).toBe(true)
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2))
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
expect(rig.adapter.acquire).toHaveBeenCalledTimes(1)
holds.mockReturnValue(false)
})
})
@@ -126,7 +126,11 @@ describe('a registered definition', () => {
const options = await readAtRest(agents)
expect(options.models.map((model) => model.id)).toEqual(['pilot-model'])
// No pick for effort: the registered rules read the model's default.
expect(options.current).toEqual({ model: 'pilot-model', effort: 'low' })
expect(options.current).toEqual({
model: 'pilot-model',
effort: 'low',
permissionMode: 'ask'
})
expect(options.threadGoal).toEqual({ current: null })
})
@@ -5,6 +5,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
/** A spawned child with no stdout frame or stderr line for this long has stopped starting. */
export const STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS = 60_000
@@ -28,6 +29,8 @@ export type StructuredAgentSessionStartupAttempt = {
readonly launch: Pick<AgentSessionRecord, 'location' | 'accountHome' | 'launchDirectory'>
/** The saved options the start launches with, as intent; never a catalog guess. */
readonly options?: Readonly<Record<string, string>>
/** The permission mode the start launches with; absent for an agent with none. */
readonly permissionMode?: AgentChatPermissionMode
/** Provider events may begin before acquisition returns. */
readonly events?: StructuredAgentSessionEventSink
/** Background work this start's child reports, scoped to the attempt. Unset until the host routes
@@ -14,6 +14,7 @@ import { randomUUID } from 'node:crypto'
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types'
import { sameProviderChild } from './structured-agent-session-provider-child'
import {
@@ -26,6 +27,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: {
record: AgentSessionRecord
identity: AgentSessionJournalIdentity
spawnToken: string
permissionMode?: AgentChatPermissionMode
events?: StructuredAgentSessionEventSink
signal?: AbortSignal
optionRevision: () => number
@@ -42,6 +44,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: {
...(record.launchDirectory === undefined ? {} : { launchDirectory: record.launchDirectory })
},
...(record.options ? { options: record.options } : {}),
...(input.permissionMode ? { permissionMode: input.permissionMode } : {}),
...(input.events ? { events: input.events } : {}),
...(input.signal ? { signal: input.signal } : {}),
optionRevision: input.optionRevision
@@ -31,6 +31,7 @@ export function structuredStatusSummariesEqual(
(a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) &&
a.latestPrompt === b.latestPrompt &&
a.model === b.model &&
a.permissionMode === b.permissionMode &&
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
a.lastAssistantMessage === b.lastAssistantMessage &&
@@ -7,6 +7,10 @@ import type { StructuredAgentSessionStatusState } from './structured-agent-sessi
import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types'
import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result'
import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory'
import {
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
isAgentChatPermissionMode
} from '../../../shared/agent-chat-permission-mode'
export function structuredAgentSessionStatusSummary({
sessionId,
@@ -37,6 +41,7 @@ export function structuredAgentSessionStatusSummary({
// The journal has no model: the record's acknowledged options are where a mid-session
// switch lands, so the row follows whichever is in force.
const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH)
const permissionMode = record?.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID]
const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined
return {
sessionId,
@@ -56,6 +61,7 @@ export function structuredAgentSessionStatusSummary({
? { rewindBlockedReason: 'outcome-unknown' as const }
: {}),
...(model ? { model } : {}),
...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}),
...childWork,
...(providerSession ? { providerSession } : {}),
...(launchDirectory ? { launchDirectory } : {}),
@@ -21,7 +21,6 @@ import {
import { nativeChatVisualsFolderFor } from './native-chat-visuals-folder'
const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' }
const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const
const scratch: string[] = []
afterEach(() => {
for (const dir of scratch.splice(0)) {
@@ -93,7 +92,6 @@ describe('inline visuals launch preference', () => {
resolveWorkspacePath: async () => '/repo',
resolveCommand: () => '/bin/codex',
resolveLaunchArgs: () => [],
resolvePermissionPolicy: () => MANUAL,
resolveEnvironment: async () => ({
[NATIVE_CHAT_VISUALS_DIR_ENV]: '/inherited',
KEEP: 'yes'
@@ -6,6 +6,7 @@ import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-setting
import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { applyAgentPermissionMode } from '../../../shared/tui-agent-permissions'
// Simulates a profile created before the dedicated Experimental switch was persisted.
function normalizeLegacyProfile(overrides: Record<string, unknown>): PersistedState['settings'] {
@@ -125,3 +126,25 @@ describe('chat appearance settings', () => {
).toBeUndefined()
})
})
describe('the new-chat permission setting', () => {
it.each([
['manual', 'ask'],
['yolo', 'full-access']
] as const)('seeds a profile without it from %s Agent Permissions', (mode, expected) => {
const normalized = normalizeLegacyProfile({
nativeChatPermissionMode: undefined,
...applyAgentPermissionMode({ mode })
})
expect(normalized.nativeChatPermissionMode).toBe(expected)
})
// Seeded once: after the next save the setting is the user's, not a mirror of Agent Permissions.
it('keeps a saved value when Agent Permissions change afterwards', () => {
const normalized = normalizeLegacyProfile({
nativeChatPermissionMode: 'ask',
...applyAgentPermissionMode({ mode: 'yolo' })
})
expect(normalized.nativeChatPermissionMode).toBe('ask')
})
})
@@ -9,6 +9,7 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t
import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai'
import { normalizeUiLanguage } from '../../../shared/ui-language'
import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat-shell-environment'
import { loadedChatPermissionSetting } from '../../../shared/agent-chat-permission-mode'
import {
savedChatUiWithTerminalDefaultView,
stripRetiredGlobalSettings
@@ -133,6 +134,11 @@ export function normalizeLoadedGlobalSettings(
nativeChatShellEnvironmentVariables: normalizeNativeChatShellEnvironmentVariables(
parsed.settings?.nativeChatShellEnvironmentVariables
),
// Why: the next settings save stores the seed; until then a reload derives the same one.
nativeChatPermissionMode: loadedChatPermissionSetting(
parsed.settings?.nativeChatPermissionMode,
migratedAgentYoloDefaults
),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
visibleTaskProvidersDefaultedForJira: true,
@@ -76,7 +76,9 @@ exports[`native-chat launch parity > claude floating fresh 1`] = `
"nodeEnv": {},
},
},
"launchPosture": {},
"launchPosture": {
"permissionMode": "default",
},
"pinnedLaunchDirectories": [
"<root>/floating",
],
@@ -159,7 +161,9 @@ exports[`native-chat launch parity > claude floating resumed 1`] = `
"nodeEnv": {},
},
},
"launchPosture": {},
"launchPosture": {
"permissionMode": "default",
},
"pinnedLaunchDirectories": [],
}
`;
@@ -240,7 +244,9 @@ exports[`native-chat launch parity > claude worktree fresh 1`] = `
"nodeEnv": {},
},
},
"launchPosture": {},
"launchPosture": {
"permissionMode": "default",
},
"pinnedLaunchDirectories": [],
}
`;
@@ -398,7 +404,9 @@ exports[`native-chat launch parity > claude worktree fresh worker 1`] = `
"nodeEnv": {},
},
},
"launchPosture": {},
"launchPosture": {
"permissionMode": "default",
},
"pinnedLaunchDirectories": [],
}
`;
@@ -479,7 +487,9 @@ exports[`native-chat launch parity > claude worktree resumed 1`] = `
"nodeEnv": {},
},
},
"launchPosture": {},
"launchPosture": {
"permissionMode": "default",
},
"pinnedLaunchDirectories": [],
}
`;
@@ -625,6 +635,7 @@ exports[`native-chat launch parity > codex floating fresh 1`] = `
"launchPosture": {
"permissionPolicy": {
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": "workspace-write",
},
},
@@ -693,6 +704,7 @@ exports[`native-chat launch parity > codex floating resumed 1`] = `
"launchPosture": {
"permissionPolicy": {
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": "workspace-write",
},
},
@@ -759,6 +771,7 @@ exports[`native-chat launch parity > codex worktree fresh 1`] = `
"launchPosture": {
"permissionPolicy": {
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": "workspace-write",
},
},
@@ -824,6 +837,7 @@ exports[`native-chat launch parity > codex worktree fresh configured 1`] = `
"launchPosture": {
"permissionPolicy": {
"approvalPolicy": "never",
"approvalsReviewer": "user",
"sandbox": "danger-full-access",
},
},
@@ -891,6 +905,7 @@ exports[`native-chat launch parity > codex worktree fresh worker 1`] = `
"launchPosture": {
"permissionPolicy": {
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": "workspace-write",
},
},
@@ -957,6 +972,7 @@ exports[`native-chat launch parity > codex worktree resumed 1`] = `
"launchPosture": {
"permissionPolicy": {
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": "workspace-write",
},
},
@@ -1022,6 +1038,7 @@ exports[`native-chat launch parity > codex worktree resumed configured 1`] = `
"launchPosture": {
"permissionPolicy": {
"approvalPolicy": "never",
"approvalsReviewer": "user",
"sandbox": "danger-full-access",
},
},
@@ -82,14 +82,14 @@ describe('a Claude start that Orca fails while the CLI is still running', () =>
await expect(
host.attach(
CALLER,
claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } })
claude.attachParams(SESSION, null, { options: { permissionMode: 'accept-edits' } })
)
).resolves.toMatchObject({ ok: true })
await vi.waitFor(() =>
expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready')
)
expect(claude.child(SESSION).launch.options.permissionMode).toBe('plan')
expect(claude.child(SESSION).launch.options.permissionMode).toBe('acceptEdits')
expect(claude.child(SESSION).calls).not.toContain('set_permission_mode')
expect(await failureRows(host)).toEqual([])
})
@@ -109,7 +109,7 @@ describe('a Claude start whose CLI answers initialize but not a control request'
claude.behave(SESSION, { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS })
vi.spyOn(console, 'warn').mockImplementation(() => {})
const host = await claude.install()
const saved = { model: 'sonnet', permissionMode: 'plan' }
const saved = { model: 'sonnet', permissionMode: 'accept-edits' }
await expect(
host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved }))
).resolves.toMatchObject({ ok: true })
@@ -119,7 +119,7 @@ describe('a Claude start whose CLI answers initialize but not a control request'
})
expect(claude.child(SESSION).launch.options).toMatchObject({
model: 'sonnet',
permissionMode: 'plan'
permissionMode: 'acceptEdits'
})
// The record keeps the saved choices through the start.
await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), {
@@ -152,8 +152,8 @@ describe('a Claude start whose CLI answers initialize but not a control request'
// The CLI answers again; the user sets another option on the running child.
behavior.optionWritesHang = false
await setOption(host, 'permissionMode', 'plan')
expect(record(host)?.options).toMatchObject({ model: 'sonnet', permissionMode: 'plan' })
await setOption(host, 'permissionMode', 'accept-edits')
expect(record(host)?.options).toMatchObject({ model: 'sonnet', permissionMode: 'accept-edits' })
})
it('launches the saved model again after a turn reports another model, another option changes, and the chat is cleared', async () => {
@@ -183,8 +183,13 @@ describe('a Claude start whose CLI answers initialize but not a control request'
expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' })
behavior.optionWritesHang = false
await setOption(host, 'permissionMode', 'plan')
expect(record(host)?.options).toEqual({ model: 'sonnet', permissionMode: 'plan' })
// The permission mode is the record's alone; the running child is not asked.
await setOption(host, 'permissionMode', 'accept-edits')
expect(record(host)?.options).toEqual({
model: 'sonnet',
effort: 'high',
permissionMode: 'accept-edits'
})
const cleared = await host.conversationCommand(CALLER, {
command: 'clear',
@@ -217,14 +222,14 @@ describe('a Claude start whose CLI answers initialize but not a control request'
expect(claude.child(SESSION).launch.options).toMatchObject({
sessionId: freshProviderSessionId,
model: 'sonnet',
permissionMode: 'plan'
permissionMode: 'acceptEdits'
})
expect(claude.child(SESSION).launch.options.resume).toBeUndefined()
await vi.waitFor(() =>
expect(record(host)?.options).toEqual({
model: 'sonnet',
effort: 'high',
permissionMode: 'plan'
permissionMode: 'accept-edits'
})
)
})
@@ -12,7 +12,7 @@ import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support'
import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations'
import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults'
import { structuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults'
import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime'
import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
@@ -94,10 +94,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR
structuredAgentSessionLaunchSeedOptions(
agent: StructuredAgentId
): Record<string, string> | undefined {
return resolveStructuredLaunchSeedOptions(
this.requireStore().getSettings().nativeChatSessionOptions,
agent
)
return structuredLaunchSeedOptions(this.requireStore().getSettings(), agent)
}
protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
@@ -46,8 +46,6 @@ import {
} from '../../shared/tui-agent-launch-defaults'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment'
import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode'
import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy'
import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy'
import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution'
import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args'
@@ -226,12 +224,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis
nativeChatShellEnvironmentPolicy(this.requireStore().getSettings()),
resolveClaudeAuthPolicy: () =>
claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()),
// Re-read per acquisition, like the auth policy above it: the Agent Permissions setting is
// the one copy of this fact, even when Arguments contain permission flags.
resolveClaudePermissionMode: () =>
claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()),
resolveCodexPermissionPolicy: () =>
codexStructuredPermissionPolicyForSettings(this.requireStore().getSettings()),
resolveAgentFullAccess: (agent) =>
isTuiAgent(agent) &&
resolvedTuiAgentArgsBypassPermissions(
@@ -16,7 +16,7 @@ import type { MemorySnapshot, StatsSummary } from '../../shared/process-stats-ty
import { collectMemorySnapshot } from '../memory/collector'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { FeatureInteractionId } from '../../shared/feature-interactions'
import type { RuntimeClientSettingsUpdate } from './runtime-client-settings'
import type { RuntimeClientSettingsUpdate } from './runtime-client-settings-shape'
import type { AgentLaunchSettingsMutation } from '../../shared/agent-launch-settings'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import type { TerminalQuickCommandMutation } from '../../shared/terminal-quick-commands'
@@ -148,7 +148,12 @@ describe('structured agent-session create intent', () => {
variable: 'CODEX_HOME',
path: '/accounts/selected/home'
})
expect(intent.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'true' })
expect(intent.options).toEqual({
model: 'gpt-5.6-sol',
effort: 'medium',
fastMode: 'true',
permissionMode: 'ask'
})
})
it('resolves the record-less catalog account home read-only, never through launch preparation', async () => {
@@ -232,7 +237,12 @@ describe('structured agent-session create intent', () => {
variable: 'CLAUDE_CONFIG_DIR',
path: '/configured/claude-home'
})
expect(intent.options).toEqual({ model: 'opus', effort: 'high', fastMode: 'true' })
expect(intent.options).toEqual({
model: 'opus',
effort: 'high',
fastMode: 'true',
permissionMode: 'ask'
})
// createSupport reports this same seed, so a paired client's picker shows what create runs.
expect(runtime.structuredAgentSessionLaunchSeedOptions('claude')).toEqual(intent.options)
})
@@ -208,6 +208,32 @@ it('merges held option overrides with host seeds before the first acquisition',
)
})
it('keeps the seeded permission mode beside explicit picks on an acquired create', async () => {
// The fixture's Codex intent, seeded with a permission mode.
const resolveStructuredAgentSessionCreateIntent = async (input: { envelope: unknown }) => ({
envelope: input.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
options: { model: 'gpt-5.6-sol', effort: 'medium', permissionMode: 'full-access' },
runtimeKind: 'native'
})
expect(
await call('agentSession.create', params({ model: 'held-model' }), legacy, {
resolveStructuredAgentSessionCreateIntent
})
).toMatchObject({ ok: true, result: { ok: true } })
expect(rig.adapter.acquire).toHaveBeenCalledWith(
expect.objectContaining({ options: { model: 'held-model', permissionMode: 'full-access' } })
)
})
it('rejects an explicit option the resting agent would reject before founding', async () => {
expect(
await call('agentSession.create', params({ unknownOption: 'yes' }), capable)
@@ -1,5 +1,6 @@
// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path.
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
import { refuse, AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
import type {
@@ -129,8 +130,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
attachParams: {
...resolvedAttach,
// The ledger covers explicit overrides, never defaults that settings can change on replay.
// Explicit picks replace the saved selection; the permission default is the chat's own.
...(args.options
? { options: atRest ? { ...resolved.options, ...args.options } : args.options }
? {
options: atRest
? { ...resolved.options, ...args.options }
: { ...seededPermissionMode(resolved.options), ...args.options }
}
: {}),
...(args.tabId ? { surfaceTabId: args.tabId } : {}),
provider: resolved.provider,
@@ -238,3 +244,10 @@ export async function createStructuredAgentSessionForWorktree(args: {
activate: args.activate
})
}
function seededPermissionMode(
options: Readonly<Record<string, string>> | undefined
): Record<string, string> {
const mode = options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID]
return mode ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : {}
}
@@ -0,0 +1,74 @@
// What a paired client reads and may write of this host's settings.
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { SourceControlAiActionDefaults } from '../../shared/source-control-ai-actions'
export type RuntimeClientSettings = Pick<
GlobalSettings,
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'agentStatusHooksEnabled'
| 'terminalCopyTrimsGutter'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'experimentalNativeChat'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
| 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'artifactSharingEnabled'
| 'worktreeVisibilityDefaults'
| 'agentSkillSharingEnabled'
| 'machineName'
| 'nativeChatPermissionMode'
> & {
/** Older clients still read these; neither is persisted or accepted as an update. */
openAgentTabsInChatByDefault?: boolean
experimentalStructuredNativeChat?: boolean
hostSettingOverrides: RuntimeHostDisplayLabelOverrides
sourceControlAi: RuntimeClientSourceControlAi
}
/** The saved per-action launch recipes (agent, prompt template, agent args), already migrated. */
export type RuntimeClientSourceControlAi = { actions: SourceControlAiActionDefaults }
/** Safe paired projection: host labels only; filesystem defaults stay host-private. */
export type RuntimeHostDisplayLabelOverrides = Partial<
Record<ExecutionHostId, { displayLabel: string }>
>
export type RuntimeClientSettingsUpdate = Pick<
Partial<GlobalSettings>,
| 'agentStatusHooksEnabled'
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
| 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'worktreeVisibilityDefaults'
| 'machineName'
| 'nativeChatPermissionMode'
>
+11 -72
View File
@@ -10,15 +10,11 @@ import {
} from '../../shared/terminal-quick-commands'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai'
import {
SOURCE_CONTROL_LAUNCH_ACTION_IDS,
type SourceControlAiActionDefaults
} from '../../shared/source-control-ai-actions'
import { SOURCE_CONTROL_LAUNCH_ACTION_IDS } from '../../shared/source-control-ai-actions'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { applyNativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-option-defaults'
import type { NativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-options'
import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
@@ -31,73 +27,12 @@ import {
type AgentLaunchSettingsMutation
} from '../../shared/agent-launch-settings'
import { agentLaunchSettingsRollbackUpdates } from './agent-launch-settings-rollback'
export type RuntimeClientSettings = Pick<
GlobalSettings,
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'agentStatusHooksEnabled'
| 'terminalCopyTrimsGutter'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'experimentalNativeChat'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
| 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'artifactSharingEnabled'
| 'worktreeVisibilityDefaults'
| 'agentSkillSharingEnabled'
| 'machineName'
> & {
/** Older clients still read these; neither is persisted or accepted as an update. */
openAgentTabsInChatByDefault?: boolean
experimentalStructuredNativeChat?: boolean
hostSettingOverrides: RuntimeHostDisplayLabelOverrides
sourceControlAi: RuntimeClientSourceControlAi
}
/** The saved per-action launch recipes (agent, prompt template, agent args), already migrated. */
export type RuntimeClientSourceControlAi = { actions: SourceControlAiActionDefaults }
/** Safe paired projection: host labels only; filesystem defaults stay host-private. */
export type RuntimeHostDisplayLabelOverrides = Partial<
Record<ExecutionHostId, { displayLabel: string }>
>
export type RuntimeClientSettingsUpdate = Pick<
Partial<GlobalSettings>,
| 'agentStatusHooksEnabled'
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
| 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'worktreeVisibilityDefaults'
| 'machineName'
>
import type {
RuntimeClientSettings,
RuntimeClientSettingsUpdate,
RuntimeClientSourceControlAi,
RuntimeHostDisplayLabelOverrides
} from './runtime-client-settings-shape'
export class RuntimeClientSettingsController {
private reconciliationGeneration = 0
@@ -150,6 +85,10 @@ export class RuntimeClientSettingsController {
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings),
machineName: settings.machineName ?? '',
// Why projected: a web client's Settings → Chat edits this host's new-chat default.
...(settings.nativeChatPermissionMode
? { nativeChatPermissionMode: settings.nativeChatPermissionMode }
: {}),
// Why projected: a paired client's AI buttons start these actions' agents, and must honour
// the agent saved for each one as the desktop does. Absent on older hosts.
sourceControlAi: projectSourceControlLaunchRecipes(settings),
@@ -133,6 +133,7 @@ export type RuntimeStore = {
hostSettingOverrides?: GlobalSettings['hostSettingOverrides']
agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled']
nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions']
nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode']
nativeChatInlineVisuals?: GlobalSettings['nativeChatInlineVisuals']
nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment']
nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables']
@@ -2,6 +2,7 @@
// it, so resolver tests exercise the shared launch path rather than a copy of it.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import type { AcpLaunchSpec } from '../acp/acp-launch-specs'
import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions'
import {
@@ -74,11 +75,19 @@ function withTestSpawnToken<L>(
) {
const resolve = composeStructuredLaunch(definition, sources, part)
const read = composeStructuredLaunchRead(definition, sources, part)
return (input: { identity: AgentSessionJournalIdentity; spawnToken?: string | null }) => {
return (input: {
identity: AgentSessionJournalIdentity
spawnToken?: string | null
permissionMode?: AgentChatPermissionMode
}) => {
const spawnToken = input.spawnToken === undefined ? defaultToken : input.spawnToken
return spawnToken === null
? read(input.identity)
: resolve({ identity: input.identity, spawnToken })
: resolve({
identity: input.identity,
spawnToken,
...(input.permissionMode ? { permissionMode: input.permissionMode } : {})
})
}
}
@@ -4,6 +4,7 @@
// sealed environment; it only adds what is that agent's.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { isNativeLocalLocation } from '../../shared/execution-host'
import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition'
@@ -47,12 +48,15 @@ export type StructuredAgentLaunchBasis = {
/** Null when the chat gets no visuals. */
visuals(): Promise<NativeChatVisualsLaunch | null>
commandSettings(): StructuredAgentCommandSettings
/** The permission mode the host decided this start runs with; absent asks. */
permissionMode?: AgentChatPermissionMode
}
export type StructuredLaunchRequest = {
identity: AgentSessionJournalIdentity
/** The reservation's token, which the seal hands the child. */
spawnToken: string
permissionMode?: AgentChatPermissionMode
}
export type StructuredAgentLaunchPart<L> = {
@@ -126,7 +130,8 @@ async function composedPart<L>(
definition: StructuredAgentDefinition,
sources: StructuredAgentLaunchSources,
part: StructuredAgentLaunchPartResolver<L>,
identity: AgentSessionJournalIdentity
identity: AgentSessionJournalIdentity,
permissionMode?: AgentChatPermissionMode
): Promise<StructuredAgentLaunchPart<L> & { cwd: string }> {
const record = admittedRecord(definition, sources.store, identity.sessionId)
const launchEnv = once(() => sources.resolveAgentLaunchEnv?.(definition.agent) ?? {})
@@ -137,7 +142,8 @@ async function composedPart<L>(
launchEnv,
environment: once(() => sources.resolveAgentEnvironment(definition.agent)),
visuals: once(async () => (await sources.prepareVisuals?.(record.sessionId)) ?? null),
commandSettings: once(() => sources.resolveCommandSettings?.() ?? {})
commandSettings: once(() => sources.resolveCommandSettings?.() ?? {}),
...(permissionMode ? { permissionMode } : {})
}
const composed = await part(basis, identity)
return { ...composed, cwd: await basis.launchDirectory() }
@@ -148,12 +154,13 @@ export function composeStructuredLaunch<L>(
sources: StructuredAgentLaunchSources,
part: StructuredAgentLaunchPartResolver<L>
): StructuredAgentLaunchResolver<L> {
return async ({ identity, spawnToken }) => {
return async ({ identity, spawnToken, permissionMode }) => {
const { launch, cwd, env, inheritedEnvToDelete, cliRuntimeCommand } = await composedPart(
definition,
sources,
part,
identity
identity,
permissionMode
)
const child = sealStructuredSessionChild({
sessionId: identity.sessionId,
@@ -126,6 +126,7 @@ import {
} from '../../shared/agent-hook-scrub-safe-env'
import { STRUCTURED_WORKER_HANDLE_PREFIX } from '../../shared/structured-worker-handle'
import { YOLO_TUI_AGENT_ARGS } from '../../shared/tui-agent-permissions'
import { agentChatPermissionModes } from '../../shared/agent-chat-permission-mode'
import {
AGENT_HOOK_RUNTIME_ENV_KEYS,
ORCA_AGENT_SESSION_CALLER_ENV_KEYS
@@ -637,6 +638,10 @@ describe.skipIf(process.platform === 'win32')('native-chat launch parity', () =>
},
fence: 1,
spawnToken: SPAWN_TOKEN,
// Claude and Codex chats run their own mode, which the host hands the start.
...(launch.configured && agentChatPermissionModes(launch.agent)
? { permissionMode: 'full-access' as const }
: {}),
events: eventSink()
}
capture.spawns.length = 0
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentSessionAccountHome, AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionModelOption } from '../../shared/agent-session-wire'
import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog'
import { agentChatPermissionModes } from '../../shared/agent-chat-permission-mode'
import {
applyStructuredAgentSessionModelCatalog,
applyStructuredAgentSessionOptions,
@@ -33,6 +34,9 @@ import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/struc
import { PI_RPC_AGENT } from '../pi/rpc-agent-definition'
import { piModelCatalogFromListing } from '../pi/rpc-model-catalog-probe'
// A chat whose agent offers permission modes also names its own, which a new chat leaves at Ask.
const modeOf = (agent: string) => (agentChatPermissionModes(agent) ? { permissionMode: 'ask' } : {})
const unused = (): never => {
throw new Error('an at-rest read never calls the adapter')
}
@@ -259,7 +263,7 @@ describe("a new chat's effort pill before its agent starts", () => {
expect(first.model?.value).toBe(model)
expect(first.effort?.value).toBe(effort)
expect(settled).toEqual(first)
expect(atRest.current).toEqual({ model, effort })
expect(atRest.current).toEqual({ model, effort, ...modeOf(agent) })
}
)
@@ -276,7 +280,7 @@ describe("a new chat's effort pill before its agent starts", () => {
expect(first.model?.value).toBeUndefined()
expect(first.effort).toBeNull()
expect(settled).toEqual(first)
expect(atRest.current).toEqual({})
expect(atRest.current).toEqual(modeOf(agent))
}
)
@@ -287,14 +291,14 @@ describe("a new chat's effort pill before its agent starts", () => {
})
// The account default is not what this repo runs, so its effort is never claimed.
expect(atRest.current).toEqual({ model: 'gpt-5.5' })
expect(atRest.current).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' })
expect(atRest.conversationCommands).toEqual(['clear', 'compact'])
})
it('names the folder’s default effort for a client that needs a model too', async () => {
const { atRest } = await newChatFrames('codex', { readsWithoutModel: false })
expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium' })
expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium', permissionMode: 'ask' })
})
it('keeps a saved effort over the default model’s', async () => {
@@ -307,6 +311,6 @@ describe("a new chat's effort pill before its agent starts", () => {
it('leaves the effort of a saved model to the agent, as its running child reports it', async () => {
const { atRest } = await newChatFrames('codex', { saved: { model: 'gpt-5.5-mini' } })
expect(atRest.current).toEqual({ model: 'gpt-5.5-mini' })
expect(atRest.current).toEqual({ model: 'gpt-5.5-mini', permissionMode: 'ask' })
})
})
@@ -141,9 +141,6 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA
structuredAgentLaunchSources(context),
codexStructuredLaunchPart({
resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'),
...(deps.resolveCodexPermissionPolicy
? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy }
: {}),
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
})
),
@@ -181,9 +178,6 @@ function createClaudeAdapter(
resolveLaunchArgs: () => deps.resolveLaunchArgs('claude'),
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
resolveAuthPolicy: deps.resolveClaudeAuthPolicy,
...(deps.resolveClaudePermissionMode
? { resolvePermissionMode: deps.resolveClaudePermissionMode }
: {}),
attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory),
...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {})
})
@@ -12,7 +12,6 @@
import type { AgentSessionAccountKind } from '../../shared/agent-session-availability'
import type { PiRpcSessionDeps } from '../pi/rpc-session'
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import { existsSync } from 'node:fs'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker'
@@ -23,7 +22,6 @@ import {
type InstalledRuntime
} from './structured-agent-session-runtime-teardown'
import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule'
import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy'
import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution'
import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter'
import type { readCodexCliInstallation } from '../preflight/codex-cli-installation'
@@ -123,11 +121,7 @@ export type StructuredAgentSessionRuntimeDeps = {
resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise<string[]> | string[]
/** Required, and asserted at install time — an absent policy must not degrade to a guess. */
resolveClaudeAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
/** The user's Agent Permissions setting for Claude; absent means prompting. */
resolveClaudePermissionMode?: () => Promise<PermissionMode> | PermissionMode
/** The same setting for Codex, as app-server thread policy. */
resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy
/** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */
/** Agent Permissions for an ACP agent; Claude and Codex chats run their own permission mode. */
resolveAgentFullAccess?: (agent: string) => boolean
/** The user's per-agent environment overlay from settings, laid over every agent's base env. */
resolveAgentLaunchEnv?: (agent: string) => Record<string, string>
@@ -404,6 +404,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
sessionOptionsSnapshot={sessionOptionsSnapshot}
contextUsage={contextUsageSummary}
sessionOptionsPickerRequest={structuredTransport?.optionPickerRequest ?? null}
agent={agent}
/>
)
}
@@ -7,6 +7,7 @@ import type {
SessionOptionsSurface
} from '../../../../shared/native-chat-session-options'
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
import { NativeChatComposerPermissionPicker } from './NativeChatPermissionModePicker'
import { NativeChatComposerGoalChip } from './NativeChatComposerGoalChip'
import { NativeChatContextUsageRing } from './NativeChatContextUsageRing'
import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary'
@@ -44,6 +45,8 @@ export type NativeChatComposerActionsProps = {
onExitGoalMode?: () => void
/** Absent until the session has reported or the transcript can estimate. */
contextUsage?: NativeChatContextUsageSummary | null
/** The chat's agent, whose permission modes the picker describes. */
agent?: string
}
export function NativeChatComposerActions({
@@ -69,7 +72,8 @@ export function NativeChatComposerActions({
sessionOptionsPickerRequest,
onExitGoalMode,
contextUsage,
focusComposer
focusComposer,
agent
}: NativeChatComposerActionsProps): React.JSX.Element {
const stops = primaryAction === 'stop'
const resumes = primaryAction === 'resume'
@@ -141,6 +145,11 @@ export function NativeChatComposerActions({
{translate('components.native-chat.composer.attach', 'Attach file')}
</TooltipContent>
</Tooltip>
<NativeChatComposerPermissionPicker
surface={sessionOptionsSurface}
snapshot={sessionOptionsSnapshot}
{...(agent ? { agent } : {})}
/>
{onExitGoalMode ? <NativeChatComposerGoalChip onExit={onExitGoalMode} /> : null}
</div>
<div className="ml-auto flex items-center gap-1.5">
@@ -86,6 +86,7 @@ export type NativeChatComposerFieldProps = {
contextUsage?: NativeChatContextUsageSummary | null
sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null
goalMode?: NativeChatComposerGoalMode
agent?: string
}
export type NativeChatComposerImageAttachment = {
@@ -170,7 +171,8 @@ export function NativeChatComposerField({
sessionOptionsSnapshot,
contextUsage,
sessionOptionsPickerRequest,
goalMode
goalMode,
agent
}: NativeChatComposerFieldProps): React.JSX.Element {
const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey)
const optionCount =
@@ -382,6 +384,7 @@ export function NativeChatComposerField({
sessionOptionsPickerRequest={sessionOptionsPickerRequest}
focusComposer={focusComposer}
onExitGoalMode={goalMode?.active ? goalMode.exit : undefined}
{...(agent ? { agent } : {})}
/>
</div>
</div>
@@ -0,0 +1,252 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string | number>) =>
Object.entries(values ?? {}).reduce(
(text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)),
fallback
)
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
)
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/dropdown-menu', async () => {
const React = await import('react')
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({
children,
disabled
}: {
children: React.ReactNode
disabled?: boolean
}) => (
<div data-testid="permission-trigger" data-disabled={disabled || undefined}>
{children}
</div>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="permission-menu">{children}</div>
),
DropdownMenuRadioGroup: ({
children,
value,
onValueChange,
'aria-label': ariaLabel
}: {
children: React.ReactNode
value?: string
onValueChange?: (value: string) => void
'aria-label'?: string
}) => (
<div role="radiogroup" aria-label={ariaLabel}>
{React.Children.map(children, (child) => {
if (
!React.isValidElement<{
value?: string
disabled?: boolean
children?: React.ReactNode
onSelect?: () => void
}>(child)
) {
return child
}
const props = child.props
return (
<button
role="radio"
aria-checked={props.value === value}
disabled={props.disabled}
data-value={props.value}
onClick={() => {
props.onSelect?.()
if (props.value !== undefined) {
onValueChange?.(props.value)
}
}}
>
{props.children}
</button>
)
})}
</div>
),
DropdownMenuRadioItem: ({ children }: { children: React.ReactNode; value: string }) => (
<span>{children}</span>
)
}
})
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
import {
NativeChatComposerPermissionPicker,
NativeChatPermissionModePicker,
type NativeChatPermissionModePickerProps
} from './NativeChatPermissionModePicker'
function props(
overrides: Partial<NativeChatPermissionModePickerProps> = {}
): NativeChatPermissionModePickerProps {
return {
agent: 'claude',
current: 'ask',
modes: ['ask', 'accept-edits', 'auto', 'full-access'],
onSelect: vi.fn(),
...overrides
}
}
function option(mode: AgentChatPermissionMode): HTMLElement {
return screen.getByRole('radio', {
name: (_name, element) => element.getAttribute('data-value') === mode
})
}
afterEach(cleanup)
describe('NativeChatPermissionModePicker', () => {
it('lists each mode the agent offers, in order, with what it does', () => {
render(<NativeChatPermissionModePicker {...props()} />)
const menu = screen.getByTestId('permission-menu')
expect(
within(menu)
.getAllByRole('radio')
.map((row) => row.getAttribute('data-value'))
).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
expect(within(menu).getByText('Ask for approval')).toBeTruthy()
expect(
within(menu).getByText("Asks before edits and commands your settings don't allow")
).toBeTruthy()
expect(
within(menu).getByText('Approves file edits and file commands; asks for the rest')
).toBeTruthy()
expect(within(menu).getByText('Reviews approval requests for you')).toBeTruthy()
expect(within(menu).getByText('Never asks before edits or commands')).toBeTruthy()
})
it("describes Codex's modes as Codex runs them", () => {
render(
<NativeChatPermissionModePicker
{...props({ agent: 'codex', modes: ['ask', 'auto', 'full-access'] })}
/>
)
expect(screen.queryByText('Accept edits')).toBeNull()
expect(option('ask').textContent).toContain(
'Edits files and runs commands in this workspace; asks before anything else'
)
expect(option('full-access').textContent).toContain(
'Never asks; can edit any file and use the network'
)
})
it('describes both agents in the new-chat setting', () => {
render(<NativeChatPermissionModePicker {...props({ agent: null })} />)
expect(option('ask').textContent).toContain(
"Asks before edits or commands it isn't already allowed to make"
)
expect(option('full-access').textContent).toContain('Never asks before edits or commands')
})
it('names the current mode on the pill, by category for assistive tech', () => {
render(<NativeChatPermissionModePicker {...props({ current: 'auto' })} />)
const trigger = screen.getByRole('button', { name: 'Permissions Approve for me' })
expect(trigger.textContent).toContain('Approve for me')
expect(option('auto').getAttribute('aria-checked')).toBe('true')
})
it('shows Full access in the warning colour on the pill and in the menu', () => {
render(<NativeChatPermissionModePicker {...props({ current: 'full-access' })} />)
const trigger = screen.getByRole('button', { name: 'Permissions Full access' })
expect(trigger.querySelector('.text-status-warning')?.textContent).toBe('Full access')
expect(option('full-access').querySelector('.text-status-warning')).not.toBeNull()
expect(option('ask').querySelector('.text-status-warning')).toBeNull()
})
it('locks the pill and its rows while disabled', () => {
render(<NativeChatPermissionModePicker {...props({ disabled: true })} />)
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true')
expect(option('full-access')).toHaveProperty('disabled', true)
})
})
describe('NativeChatComposerPermissionPicker', () => {
const descriptor = (
overrides: Partial<SessionOptionDescriptor> = {}
): SessionOptionDescriptor => ({
id: 'permissionMode',
label: 'Permissions',
category: 'mode',
kind: {
type: 'select',
currentValue: 'ask',
choices: [
{ value: 'ask', label: 'Ask for approval' },
{ value: 'auto', label: 'Approve for me' },
{ value: 'full-access', label: 'Full access' }
]
},
valueSource: 'applied',
transport: 'agent-session',
settable: true,
...overrides
})
const surface = () => ({
getSnapshot: () => [],
setOption: vi.fn(async () => ({ snapshot: [] })),
invokeAction: vi.fn(async () => ({ snapshot: [] })),
subscribe: () => () => {}
})
// A host that states no mode, or an agent with none, gets no pill.
it('renders nothing without a permission descriptor', () => {
const { container } = render(
<NativeChatComposerPermissionPicker surface={surface()} snapshot={[]} agent="codex" />
)
expect(container.innerHTML).toBe('')
})
it('writes a new pick through the option surface, and skips the current one', () => {
const options = surface()
render(
<NativeChatComposerPermissionPicker
surface={options}
snapshot={[descriptor()]}
agent="codex"
/>
)
fireEvent.click(option('ask'))
expect(options.setOption).not.toHaveBeenCalled()
// A write that waits behind a starting agent leaves the pill usable.
options.setOption.mockImplementation(() => new Promise(() => {}))
fireEvent.click(option('full-access'))
expect(options.setOption).toHaveBeenCalledWith('permissionMode', 'full-access')
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBeNull()
})
it('locks only while the descriptor cannot take a pick', () => {
render(
<NativeChatComposerPermissionPicker
surface={surface()}
snapshot={[descriptor({ settable: false })]}
agent="codex"
/>
)
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true')
})
})
@@ -0,0 +1,156 @@
import { memo } from 'react'
import { ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import {
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
isAgentChatPermissionMode,
type AgentChatPermissionMode
} from '../../../../shared/agent-chat-permission-mode'
import type {
SessionOptionDescriptor,
SessionOptionsSurface
} from '../../../../shared/native-chat-session-options'
import {
NATIVE_CHAT_PERMISSION_MODE_ICONS,
nativeChatPermissionModeDescription,
nativeChatPermissionModeLabel,
nativeChatPermissionPickerTitle
} from './native-chat-permission-mode-labels'
import { runSessionOptionSurfaceCall } from './native-chat-session-option-surface-call'
/** Icon and name; Full access reads in the warning colour wherever it is shown. */
function ModeName(props: { mode: AgentChatPermissionMode; truncate?: boolean }): React.JSX.Element {
const Icon = NATIVE_CHAT_PERMISSION_MODE_ICONS[props.mode]
const label = nativeChatPermissionModeLabel(props.mode)
const body = (
<>
<Icon className="size-3.5 shrink-0" aria-hidden />
{props.truncate ? <span className="truncate">{label}</span> : <span>{label}</span>}
</>
)
return props.mode === 'full-access' ? (
<span className="flex min-w-0 items-center gap-1.5 text-status-warning">{body}</span>
) : (
<span className="flex min-w-0 items-center gap-1.5">{body}</span>
)
}
export type NativeChatPermissionModePickerProps = {
current: AgentChatPermissionMode
modes: readonly AgentChatPermissionMode[]
/** Whose descriptions to show; absent describes every agent's. */
agent?: string | null
disabled?: boolean
onSelect: (mode: AgentChatPermissionMode) => void
}
function NativeChatPermissionModePickerInner({
current,
modes,
agent,
disabled = false,
onSelect
}: NativeChatPermissionModePickerProps): React.JSX.Element {
const title = nativeChatPermissionPickerTitle()
// `shrink` overrides the button's shrink-0 so a narrow composer truncates the mode name.
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
variant="ghost"
size="xs"
aria-label={translate(
'components.native-chat.composer.pillAccessibleName',
'{{value0}} {{value1}}',
{ value0: title, value1: nativeChatPermissionModeLabel(current) }
)}
className="max-w-40 min-w-0 shrink"
>
<ModeName mode={current} truncate />
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{title}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-72">
<DropdownMenuRadioGroup aria-label={title} value={current}>
{modes.map((mode) => (
<DropdownMenuRadioItem
key={mode}
value={mode}
disabled={disabled}
onSelect={() => onSelect(mode)}
>
<div className="min-w-0 py-0.5">
<ModeName mode={mode} />
<div className="text-xs font-normal text-muted-foreground">
{nativeChatPermissionModeDescription(mode, agent)}
</div>
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const NativeChatPermissionModePicker = memo(NativeChatPermissionModePickerInner)
const NO_PENDING_MARK = (): void => {}
/** The snapshot's permission descriptor, absent where the host states none; never locked by work. */
export function NativeChatComposerPermissionPicker({
surface,
snapshot,
agent
}: {
surface: SessionOptionsSurface | null
snapshot: readonly SessionOptionDescriptor[]
agent?: string
}): React.JSX.Element | null {
const descriptor = snapshot.find((entry) => entry.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID)
const current = descriptor?.kind.type === 'select' ? descriptor.kind.currentValue : undefined
if (
!surface ||
!descriptor ||
descriptor.kind.type !== 'select' ||
!isAgentChatPermissionMode(current)
) {
return null
}
const modes = descriptor.kind.choices
.map((choice) => choice.value)
.filter(isAgentChatPermissionMode)
return (
<NativeChatPermissionModePicker
current={current}
modes={modes}
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)
)
}
}}
/>
)
}
@@ -1,6 +1,5 @@
import { memo, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { SwitchIndicator } from '@/components/ui/switch'
import {
@@ -32,9 +31,7 @@ import {
nativeChatSessionOptionLabel
} from './native-chat-session-option-labels'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
import { runSessionOptionSurfaceCall } from './native-chat-session-option-surface-call'
import { NativeChatModelCombobox } from './NativeChatModelCombobox'
import { useNativeChatPickerFocusReturn } from './native-chat-picker-focus-return'
import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody'
@@ -199,31 +196,6 @@ function DescriptorMenuRows(props: {
)
}
/** A host's error is words for its log, so it gets the table's; a local surface's error is
* already written for the person. */
function optionUpdateFailureDescription(error: unknown): string {
if (error instanceof RuntimeRpcCallError) {
return agentSessionWriteFailureText(agentSessionThrownFailure(error, error.code), 'option')
}
return error instanceof Error ? error.message : String(error)
}
function runSurfaceCall(
pendingKey: string,
setPendingId: (id: string | null) => void,
call: () => Promise<unknown>
): void {
setPendingId(pendingKey)
void call()
.catch((error) => {
toast.error(
translate('components.native-chat.composer.optionUpdateFailed', 'Could not update option'),
{ description: optionUpdateFailureDescription(error) }
)
})
.finally(() => setPendingId(null))
}
function NativeChatSessionOptionPickersInner({
surface,
snapshot,
@@ -245,10 +217,14 @@ function NativeChatSessionOptionPickersInner({
: null
const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
runSurfaceCall(descriptor.id, setPendingId, () => surface.setOption(descriptor.id, value))
runSessionOptionSurfaceCall(descriptor.id, setPendingId, () =>
surface.setOption(descriptor.id, value)
)
}
const invokeAction = (descriptor: SessionOptionDescriptor): void => {
runSurfaceCall(descriptor.id, setPendingId, () => surface.invokeAction(descriptor.id))
runSessionOptionSurfaceCall(descriptor.id, setPendingId, () =>
surface.invokeAction(descriptor.id)
)
}
const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason)
@@ -86,6 +86,7 @@ export function NativeChatStructuredSession(
composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId),
queueFollowUps,
hostStopping: hostExecution.stopping,
hostOptionStamp: hostExecution.optionStamp,
providerStarting: hostExecution.phase === 'starting',
providerRunning: hostExecution.phase !== null,
rewind: rewindHost,
@@ -63,6 +63,23 @@ export function useStructuredAgentSessionStatusSummary(
return { summary, observation }
}
/** The host status's option values as one stamp: another device's pick moves it, so options re-read. */
export function useStructuredAgentSessionOptionStamp(
sessionId: string,
target: RuntimeClientTarget
): string {
const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target])
useEffect(() => feed.activate(), [feed])
return useSyncExternalStore(
feed.subscribe,
() => {
const summary = feed.getSnapshot().get(sessionId)
return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}`
},
() => ''
)
}
/** Only the host's startup phase, so a chat re-renders when that changes, not on every status. */
export function useStructuredAgentSessionHostExecutionPhase(
sessionId: string,
@@ -91,14 +108,19 @@ export function useStructuredAgentSessionHostStopping(
)
}
/** The host's startup phase and its word on a Stop, each re-rendering the chat only on a change. */
/** Startup phase, Stop and option stamp, each re-rendering the chat only on a change. */
export function useStructuredAgentSessionHostExecution(
sessionId: string,
target: RuntimeClientTarget
): { phase: ReturnType<typeof useStructuredAgentSessionHostExecutionPhase>; stopping: boolean } {
): {
phase: ReturnType<typeof useStructuredAgentSessionHostExecutionPhase>
stopping: boolean
optionStamp: string
} {
return {
phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target),
stopping: useStructuredAgentSessionHostStopping(sessionId, target)
stopping: useStructuredAgentSessionHostStopping(sessionId, target),
optionStamp: useStructuredAgentSessionOptionStamp(sessionId, target)
}
}
@@ -0,0 +1,81 @@
import type { LucideIcon } from 'lucide-react'
import { FilePenLine, Hand, ShieldAlert, ShieldCheck } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
export const NATIVE_CHAT_PERMISSION_MODE_ICONS = {
ask: Hand,
'accept-edits': FilePenLine,
auto: ShieldCheck,
'full-access': ShieldAlert
} as const satisfies Record<AgentChatPermissionMode, LucideIcon>
export function nativeChatPermissionModeLabel(mode: AgentChatPermissionMode): string {
switch (mode) {
case 'ask':
return translate('components.native-chat.composer.permissionAsk', 'Ask for approval')
case 'accept-edits':
return translate('components.native-chat.composer.permissionAcceptEdits', 'Accept edits')
case 'auto':
return translate('components.native-chat.composer.permissionAuto', 'Approve for me')
case 'full-access':
return translate('components.native-chat.composer.permissionFullAccess', 'Full access')
}
}
/** `agent` absent describes the mode for every agent, as the new-chat setting does. */
export function nativeChatPermissionModeDescription(
mode: AgentChatPermissionMode,
agent?: string | null
): string {
switch (mode) {
case 'ask':
if (agent === 'codex') {
return translate(
'components.native-chat.composer.permissionAskCodexDescription',
'Edits files and runs commands in this workspace; asks before anything else'
)
}
if (agent !== 'claude') {
return translate(
'components.native-chat.composer.permissionAskSharedDescription',
"Asks before edits or commands it isn't already allowed to make"
)
}
return translate(
'components.native-chat.composer.permissionAskDescription',
"Asks before edits and commands your settings don't allow"
)
case 'accept-edits':
return translate(
'components.native-chat.composer.permissionAcceptEditsDescription',
'Approves file edits and file commands; asks for the rest'
)
case 'auto':
return translate(
'components.native-chat.composer.permissionAutoDescription',
'Reviews approval requests for you'
)
case 'full-access':
if (agent === 'codex') {
return translate(
'components.native-chat.composer.permissionFullAccessCodexDescription',
'Never asks; can edit any file and use the network'
)
}
if (agent !== 'claude') {
return translate(
'components.native-chat.composer.permissionFullAccessSharedDescription',
'Never asks before edits or commands'
)
}
return translate(
'components.native-chat.composer.permissionFullAccessDescription',
'Never asks before edits or commands'
)
}
}
export function nativeChatPermissionPickerTitle(): string {
return translate('components.native-chat.composer.permissions', 'Permissions')
}
@@ -0,0 +1,31 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
/** A host's error is words for its log, so it gets the table's; a local surface's error is
* already written for the person. */
function optionUpdateFailureDescription(error: unknown): string {
if (error instanceof RuntimeRpcCallError) {
return agentSessionWriteFailureText(agentSessionThrownFailure(error, error.code), 'option')
}
return error instanceof Error ? error.message : String(error)
}
/** One picker write: marks `pendingKey` until it settles and reports a failure as a toast. */
export function runSessionOptionSurfaceCall(
pendingKey: string,
setPendingId: (id: string | null) => void,
call: () => Promise<unknown>
): void {
setPendingId(pendingKey)
void call()
.catch((error) => {
toast.error(
translate('components.native-chat.composer.optionUpdateFailed', 'Could not update option'),
{ description: optionUpdateFailureDescription(error) }
)
})
.finally(() => setPendingId(null))
}
@@ -59,6 +59,8 @@ export function useHostModelCatalogUpgrade(args: {
reportedUnpickedModel?: boolean
/** The host runs the chat's agent: a reason its start gave ends with it. */
providerRunning?: boolean
/** A listing landed after the chat's options were read, which answered without it. */
onListingLanded?: () => void
activeOptionRecordRef: MutableRefObject<NativeChatSessionOptionRecord>
updateOptionState: (
update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
@@ -70,6 +72,7 @@ export function useHostModelCatalogUpgrade(args: {
enabled,
fence,
newLaunch,
onListingLanded,
optionCatalog,
sessionId,
target,
@@ -137,7 +140,16 @@ export function useHostModelCatalogUpgrade(args: {
}
let leave: (() => void) | null = null
const waitForListing = (): void => {
leave = joinHostModelListingWait(waitKey, () => read(true), apply)
leave = joinHostModelListingWait(
waitKey,
() => read(true),
(catalog) => {
apply(catalog)
if (catalog && catalog.origin !== 'unknown') {
onListingLanded?.()
}
}
)
}
if (isHostModelListingWaitInFlight(waitKey)) {
// The host already answered that its listing is running: the built-in list stands meanwhile.
@@ -175,6 +187,7 @@ export function useHostModelCatalogUpgrade(args: {
enabled,
fence,
newLaunch,
onListingLanded,
optionCatalog,
reportedUnpickedModel,
rereads,
@@ -47,6 +47,8 @@ export function useStructuredAgentSessionOptionState(args: {
seedsModel: boolean
providerVisible: boolean
providerStarting: boolean
/** The option values the host's status carries: a pick made on another device moves it. */
hostOptionStamp: string
readsBeforeStart: boolean
turnId: string | null
unloadedTurnRevisions: number | undefined
@@ -54,6 +56,7 @@ export function useStructuredAgentSessionOptionState(args: {
const {
agent,
fence,
hostOptionStamp,
identity,
newLaunch,
optionCatalog,
@@ -116,10 +119,12 @@ export function useStructuredAgentSessionOptionState(args: {
...(launch.worktree ? { worktree: launch.worktree } : {})
})
// A host answer is the account's, not the fence's: keep it rather than blank the default.
const next =
const kept =
sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin')
? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource }
: seeded
// The chat's own, which a restart for a new mode keeps: the picker never blanks between them.
const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept
optionMutationGeneration.current += 1
pendingOptionRef.current = null
optionStateRef.current = next
@@ -169,6 +174,8 @@ export function useStructuredAgentSessionOptionState(args: {
}, [
fence,
optionCatalog,
// Another device's pick moves it.
hostOptionStamp,
providerStarting,
providerVisible,
readsBeforeStart,
@@ -188,8 +195,12 @@ export function useStructuredAgentSessionOptionState(args: {
}
}, [contextRefresh])
// An options answer made before the account's listing landed listed none of it.
const rereadOptions = useCallback(() => optionsReadRef.current?.run(), [])
return {
conversationSupport,
rereadOptions,
optionState,
optionStateRef,
activeOptionRecordRef,
@@ -0,0 +1,155 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
}))
vi.mock('./native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: vi.fn()
}))
vi.mock('@/lib/structured-agent-session-launch-options', () => ({
holdStructuredAgentSessionLaunchOption: vi.fn(),
getStructuredAgentSessionLaunchSelection: () => null
}))
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots'
const mutate: StructuredAgentSessionMutate = vi.fn(async () => null)
const LOCAL_TARGET = { kind: 'local' } as const
function optionsAnswering(...modes: (string | undefined)[]): void {
let read = 0
mocks.call.mockImplementation((_target: unknown, method: string) => {
if (method !== 'agentSession.options') {
return new Promise(() => {})
}
const permissionMode = modes[Math.min(read++, modes.length - 1)]
return Promise.resolve({
models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }],
current: { model: 'model-a', ...(permissionMode ? { permissionMode } : {}) }
})
})
}
const optionReads = () =>
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.options').length
function permission(snapshot: readonly SessionOptionDescriptor[]): string | null | undefined {
const entry = snapshot.find((descriptor) => descriptor.id === 'permissionMode')
return entry?.kind.type === 'select' ? entry.kind.currentValue : null
}
function renderReopened(fence: number) {
return renderHook(
(props: { fence: number; stamp: string }) =>
useStructuredAgentSessionOptions({
agent: 'codex',
sessionId: 'session-1',
target: LOCAL_TARGET,
transportEnabled: true,
isVisible: true,
providerVisible: true,
fence: props.fence,
turnId: null,
unloadedTurnRevisions: undefined,
hostOptionStamp: props.stamp,
mutate
}),
{ initialProps: { fence, stamp: 'model-a|ask' } }
)
}
describe('the permission mode another device picks', () => {
beforeEach(() => {
mocks.call.mockReset()
resetHostModelCatalogSnapshotsForTests()
})
it('re-reads the options once when the host status moves, and shows the new mode', async () => {
optionsAnswering('ask', 'full-access')
const { result, rerender, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
expect(optionReads()).toBe(1)
rerender({ fence: 1, stamp: 'model-a|full-access' })
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('full-access'))
expect(optionReads()).toBe(2)
unmount()
})
// A restart for a new mode moves the fence; the pill never blanks before the next read answers.
it('keeps the mode across a fence of the same chat', async () => {
optionsAnswering('auto')
const { result, rerender, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('auto'))
mocks.call.mockImplementation(() => new Promise(() => {}))
rerender({ fence: 2, stamp: 'model-a|ask' })
expect(permission(result.current.optionSnapshot)).toBe('auto')
unmount()
})
it('shows no picker for a host that states no mode', async () => {
optionsAnswering(undefined)
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(optionReads()).toBe(1))
await waitFor(() =>
expect(result.current.optionSnapshot.some((descriptor) => descriptor.id === 'model')).toBe(
true
)
)
expect(permission(result.current.optionSnapshot)).toBeNull()
unmount()
})
})
describe('a permission pick on this device', () => {
beforeEach(() => {
mocks.call.mockReset()
vi.mocked(mutate).mockReset()
resetHostModelCatalogSnapshotsForTests()
})
it('shows at once and stays pickable while the write waits behind a start', async () => {
optionsAnswering('ask')
vi.mocked(mutate).mockImplementation(() => new Promise(() => {}))
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
await act(async () => {
void result.current.setStructuredOption('permissionMode', 'full-access')
})
expect(permission(result.current.optionSnapshot)).toBe('full-access')
await act(async () => {
void result.current.setStructuredOption('permissionMode', 'auto')
})
expect(permission(result.current.optionSnapshot)).toBe('auto')
expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2)
unmount()
})
it("shows the host's mode again when the write is refused", async () => {
optionsAnswering('ask')
vi.mocked(mutate).mockResolvedValue(null)
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
await act(async () => {
await result.current.setStructuredOption('permissionMode', 'full-access')
})
await waitFor(() => expect(optionReads()).toBe(2))
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
unmount()
})
})
@@ -7,6 +7,7 @@ import type {
import type { AgentType } from '../../../../shared/agent-status-types'
import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog'
import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
import {
applyStructuredAgentSessionOptions,
canSetStructuredAgentSessionOption,
@@ -43,6 +44,7 @@ export function useStructuredAgentSessionOptions(args: {
providerVisible: boolean
providerStarting?: boolean
providerRunning?: boolean
hostOptionStamp?: string
fence: number | null
turnId: string | null
unloadedTurnRevisions: number | undefined
@@ -73,6 +75,7 @@ export function useStructuredAgentSessionOptions(args: {
pendingOptionRef,
optionMutationGeneration,
updateOptionState,
rereadOptions,
conversationSupport
} = useStructuredAgentSessionOptionState({
agent,
@@ -86,6 +89,7 @@ export function useStructuredAgentSessionOptions(args: {
seedsModel: launchSeedOptions?.model !== undefined,
providerVisible,
providerStarting: args.providerStarting ?? false,
hostOptionStamp: args.hostOptionStamp ?? '',
// A new chat's host knows nothing of its model before the provider starts; a resumed one's
// holds the model it ran, so only the former waits rather than show the host's guess.
readsBeforeStart: launch?.kind !== 'new',
@@ -110,7 +114,8 @@ export function useStructuredAgentSessionOptions(args: {
optionState.catalogSource === 'live',
...(args.providerRunning ? { providerRunning: true } : {}),
activeOptionRecordRef,
updateOptionState
updateOptionState,
onListingLanded: rereadOptions
})
// What a settled pick must remember so the next launch starts where the user left off.
@@ -131,10 +136,21 @@ export function useStructuredAgentSessionOptions(args: {
const isCurrent = (): boolean =>
activeOptionRecordRef.current === targetRecord &&
optionMutationGeneration.current === mutationGeneration
pendingOptionRef.current = id
updateOptionState((current) => ({ ...current, pendingId: id }))
// Record-only: shown at once, so the picker stays usable while the write waits behind a start.
const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
if (shownAtOnce) {
updateOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOptionValues(current, { [id]: encoded })
: current
)
} else {
pendingOptionRef.current = id
updateOptionState((current) => ({ ...current, pendingId: id }))
}
let result: AgentSessionOptionResult | null = null
try {
const result = await mutate<AgentSessionOptionResult>(
result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value: encoded }
@@ -151,6 +167,11 @@ export function useStructuredAgentSessionOptions(args: {
structuredAgentSessionOptionView(currentState, launchSeedOptions, NO_HELD_OPTIONS),
committed
)
}
return Boolean(result)
} finally {
// A settled write, or a shown one that failed, reads back what the host now holds.
if (isCurrent() && (result || shownAtOnce)) {
void callStructuredAgentSession<AgentSessionOptionsResult>(
target,
'agentSession.options',
@@ -167,8 +188,6 @@ export function useStructuredAgentSessionOptions(args: {
})
.catch(() => {})
}
return Boolean(result)
} finally {
if (isCurrent()) {
pendingOptionRef.current = null
updateOptionState((current) =>
@@ -63,6 +63,8 @@ export function useStructuredAgentSession(args: {
queueFollowUps?: boolean
/** The host says a person's Stop is still ending this session's work. */
hostStopping?: boolean
/** The option values the host's status carries; a change re-reads the chat's options. */
hostOptionStamp?: string
/** The host's rewind latch and what follows a message returned by a rewind. */
rewind?: NativeChatRewindHost
}) {
@@ -115,6 +117,7 @@ export function useStructuredAgentSession(args: {
providerVisible,
providerStarting,
...(args.providerRunning ? { providerRunning: true } : {}),
...(args.hostOptionStamp ? { hostOptionStamp: args.hostOptionStamp } : {}),
fence: state.fence,
turnId: transportState.turnId,
unloadedTurnRevisions: state.unloadedTurnRevisions,
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { ChatPermissionSetting } from './ChatPermissionSetting'
import { TooltipProvider } from '../ui/tooltip'
vi.mock('../../store', () => ({
useAppStore: (selector: (value: { settingsSearchQuery: string }) => unknown) =>
selector({ settingsSearchQuery: '' })
}))
afterEach(cleanup)
// A web client unpaired from its host holds no value, so it shows no control.
it.each([undefined, 'ask', 'full-access'] as const)(
'offers the new-chat default only for a host value %s',
(mode) => {
const { container } = render(
<TooltipProvider>
<ChatPermissionSetting
settings={{ ...getDefaultSettings('/tmp'), nativeChatPermissionMode: mode }}
updateSettings={vi.fn()}
forceVisible
/>
</TooltipProvider>
)
if (mode === undefined) {
expect(container.querySelector('#chat-permissions')).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
} else {
expect(
screen.getByRole('button', {
name: `Permissions ${mode === 'ask' ? 'Ask for approval' : 'Full access'}`
})
).toBeTruthy()
}
}
)
@@ -0,0 +1,43 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { AGENT_CHAT_PERMISSION_MODES } from '../../../../shared/agent-chat-permission-mode'
import { NativeChatPermissionModePicker } from '../native-chat/NativeChatPermissionModePicker'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { getChatPermissionSearchEntry } from './chat-permission-search'
/** This host's mode for new Claude and Codex chats; each chat keeps its own once created. */
export function ChatPermissionSetting({
settings,
updateSettings,
forceVisible
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
forceVisible: boolean
}): React.JSX.Element | null {
const current = settings.nativeChatPermissionMode
if (current === undefined) {
return null
}
const entry = getChatPermissionSearchEntry()
return (
<SearchableSetting
{...entry}
id="chat-permissions"
forceVisible={forceVisible}
className="max-w-none"
>
<SettingsSubsectionHeader
title={entry.title}
description={entry.description}
action={
<NativeChatPermissionModePicker
current={current}
modes={AGENT_CHAT_PERMISSION_MODES}
onSelect={(nativeChatPermissionMode) => updateSettings({ nativeChatPermissionMode })}
/>
}
/>
</SearchableSetting>
)
}
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { ChatSettingsSection } from './ChatSettingsSection'
import { TooltipProvider } from '../ui/tooltip'
import { ActiveSettingsSectionProvider } from './SettingsSection'
import { getChatInlineVisualsSearchEntry } from './chat-inline-visuals-search'
import { matchesSettingsSearch } from './settings-search'
@@ -45,7 +46,8 @@ function renderChat(nativeChatInlineVisuals: boolean | undefined) {
showDesktopOnlySettings
isMounted
/>
</ActiveSettingsSectionProvider>
</ActiveSettingsSectionProvider>,
{ wrapper: TooltipProvider }
),
updateSettings
}
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { buildSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
import { ChatSettingsSection } from './ChatSettingsSection'
import { TooltipProvider } from '../ui/tooltip'
import { ActiveSettingsSectionProvider } from './SettingsSection'
import { getChatAppearanceSearchEntries } from './chat-appearance-search'
import { getChatNamingSearchEntry } from './chat-naming-search'
@@ -91,21 +92,23 @@ function NavigationHarness({
terminal as SettingsTerminalModel
)
return (
<ActiveSettingsSectionProvider value={activeSectionId}>
<output aria-label="Selected settings page">{activeSectionId}</output>
<ChatSettingsSection
settings={settings}
updateSettings={vi.fn()}
writeSourceControlAiSettings={async () => {}}
searchEntries={[
...getChatAppearanceSearchEntries(),
getChatNamingSearchEntry(),
getChatInlineVisualsSearchEntry()
]}
showDesktopOnlySettings
isMounted
/>
</ActiveSettingsSectionProvider>
<TooltipProvider>
<ActiveSettingsSectionProvider value={activeSectionId}>
<output aria-label="Selected settings page">{activeSectionId}</output>
<ChatSettingsSection
settings={settings}
updateSettings={vi.fn()}
writeSourceControlAiSettings={async () => {}}
searchEntries={[
...getChatAppearanceSearchEntries(),
getChatNamingSearchEntry(),
getChatInlineVisualsSearchEntry()
]}
showDesktopOnlySettings
isMounted
/>
</ActiveSettingsSectionProvider>
</TooltipProvider>
)
}

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