mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Let chats follow the agent permission setting (#32334)
* feat(agents): store each agent's permission itself instead of typing flags into Arguments Terminals and new chats read one stored choice per agent (Use the agent's own settings, Ask for approval, Accept edits, Approve for me, Full access). Orca adds the flag at launch; Arguments hold only the user's own text. A one-time move lifts Orca's flags out of saved Arguments. Older clients still see and write the flag as part of Arguments; the host translates. * fix(agents): say the all-agents switch resets agents set separately; keep primitives unstyled * test(agents): the one-time move returns every launch-profile field * Fix permission carrier ownership and surface-specific defaults * Preserve uncertain permission operands and name the option inventory * Check env entries without indexing typed agent maps * Combine Chat permission capability imports * Use folder execution-host platform for permission environment * Preserve launch map replacement and acknowledge permission saves * Keep permission feedback fallbacks statically discoverable * Provide preload platform to Agents settings render tests * Preserve Gemini approval arguments under Full access * fix(settings): retain complete environment edits through save outcomes * fix(settings): narrow local failure scope and preserve host messages * fix(settings): retire argument drafts after acknowledged saves * fix(settings): keep Goose environment editable after reset * fix(settings): show resolved arguments for legacy hosts * Preserve pending environment resets and bound launch settings work * Use agent permission choices for new chats * Let Claude resolve chat permissions from its own settings * Keep optional settings fallback in repository metadata reads * Consolidate permission report imports * Update permission choice contracts for own settings * Read repository visibility defaults through the raw settings fixtures * Match existing translated Agents settings labels * Update readback and legacy host test expectations * Align Chat behavior tests with host permission capability * Fix permission compatibility and host-owned descriptions * Prepare whole Environment edits and retire unchanged drafts * Handle null explicitly in permission report decoder * Keep Environment settings transitions in an acyclic module * Update raw settings reader test fixtures * Place test fixture safety notes on assertion starts * test: pin permission settings work and recovery contracts * test: type permission settings work fixtures * test: cover migrated permission inputs and captured CLI grammar * Publish permission reports and reuse host capability state * Combine mobile option state React imports * Honor mobile status feed capability for permission reports * test: preserve typed permissions in WSL Claude restarts * Correct permission report child and fixture type contracts * Delegate older-host chats to explicitly selected own settings * Avoid rebuilding live ACP option catalogs * test(chat): place cross-boundary permission contracts in integration project * Preserve existing chat capability gates and complete report test fixtures * fix(chat): preserve newer Claude startup permission reports * test(chat): clarify startup report fixture provenance * Show reported permission modes in chat menus * Keep reported permissions available after chat mode picks * Remove obsolete guard from concrete permission selection * Report the installed approval policy for ACP chat menus * test: keep accepted mobile mutation fixture on current fence * test: cover installed ACP approval report for own chats * test: align capability scaling contract with concrete permission menus * Ship required permission help in English runtime catalog * Adapt permission persistence test to shared menu
This commit is contained in:
@@ -3,6 +3,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AccessibilityInfo, Platform } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { structuredAgentSessionPermissionDescriptor } from '../../../src/shared/structured-agent-session-permission-option'
|
||||
import { createStructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionSelectChoice
|
||||
@@ -117,7 +119,8 @@ describe('MobileNativeChatSessionOptionPickers', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Pickers, { working: isWorking }))
|
||||
})
|
||||
return (working: boolean): void => {
|
||||
return (working: boolean, nextSnapshot = snapshot): void => {
|
||||
controller.snapshot = nextSnapshot
|
||||
act(() => renderer!.update(createElement(Pickers, { working })))
|
||||
}
|
||||
}
|
||||
@@ -347,6 +350,53 @@ describe('MobileNativeChatSessionOptionPickers', () => {
|
||||
expect(rowByText('Sonnet 5').props.accessibilityRole).toBe('radio')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude', 'accept-edits', 'Accept edits'],
|
||||
['codex', 'full-access', 'Full access'],
|
||||
['claude', 'plan', 'Plan mode'],
|
||||
['codex', 'read-only', 'Read only']
|
||||
])('uses the nullable %s/%s report without an Own row', async (agent, mode, label) => {
|
||||
const state = createStructuredAgentSessionOptionState(agent, null)
|
||||
const snapshot = (reportedPermissionMode?: string | null) => {
|
||||
const row = structuredAgentSessionPermissionDescriptor({
|
||||
...state,
|
||||
permissionMode: 'own',
|
||||
permissionOwnSupported: true,
|
||||
reportedPermissionMode
|
||||
})
|
||||
return row ? [row] : []
|
||||
}
|
||||
const update = mount(snapshot())
|
||||
expect(pill('Permissions').props).toMatchObject({
|
||||
disabled: true,
|
||||
accessibilityLabel: 'Permissions, Permissions'
|
||||
})
|
||||
for (const report of [mode, null, mode, undefined]) {
|
||||
update(false, snapshot(report))
|
||||
expect(pill('Permissions').props).toMatchObject({
|
||||
disabled: !report,
|
||||
accessibilityLabel: `Permissions, ${report ? label : 'Permissions'}`
|
||||
})
|
||||
}
|
||||
update(false, snapshot(mode))
|
||||
await act(async () => pill('Permissions').props.onPress())
|
||||
const rows = findByHostType(renderer!.root, 'BottomDrawer').findAll(
|
||||
(node) => node.props.accessibilityRole === 'radio'
|
||||
)
|
||||
expect(
|
||||
rows
|
||||
.filter((row) => row.props.accessibilityState.checked)
|
||||
.map((row) => findAllByHostType(row, 'Text')[0]?.props.children)
|
||||
).toEqual(['accept-edits', 'full-access'].includes(mode) ? [label] : [])
|
||||
expect(
|
||||
findAllByHostType(renderer!.root, 'Text').some((node) =>
|
||||
String(node.props.children).includes('own settings')
|
||||
)
|
||||
).toBe(false)
|
||||
await act(async () => rowByText('Full access').props.onPress())
|
||||
expect(setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ask', 'Ask for approval', 'Hand', 'full-access', 'Full access'],
|
||||
['full-access', 'Full access', 'ShieldAlert', 'ask', 'Ask for approval'],
|
||||
|
||||
@@ -146,17 +146,22 @@ export function MobileNativeChatSessionOptionPickers({
|
||||
const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason)
|
||||
const permissionChoice = selectedChoice(permission)
|
||||
const permissionColor = permissionChoiceColor(permissionChoice?.value)
|
||||
const fullAccess = permissionColor !== undefined
|
||||
const permissionIcon =
|
||||
!model && permissionChoice ? permissionModeIcon(permissionChoice.value) : undefined
|
||||
const segmentOptions = options.filter(showsSessionOptionAsSegments)
|
||||
const rowOptions = options.filter((descriptor) => !segmentOptions.includes(descriptor))
|
||||
const primary = model ? mobileModelPillLabel(model) : (permissionChoice?.label ?? 'Permissions')
|
||||
const primary = model
|
||||
? mobileModelPillLabel(model)
|
||||
: permission
|
||||
? mobileSessionOptionSummaryValue(permission)
|
||||
: 'Permissions'
|
||||
const secondary = model && options.length > 0 ? mobileOptionsPillLabel(options) : null
|
||||
|
||||
const closePicker = (): void => onNavigate(null)
|
||||
const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
|
||||
// A permission report can match a new explicit pick while the saved intent is Own.
|
||||
if (
|
||||
descriptor !== permission &&
|
||||
descriptor.valueSource !== 'unknown' &&
|
||||
descriptor.kind.type === 'select' &&
|
||||
descriptor.kind.currentValue === value
|
||||
@@ -186,14 +191,14 @@ export function MobileNativeChatSessionOptionPickers({
|
||||
<ModelPill
|
||||
primary={primary}
|
||||
secondary={secondary}
|
||||
warning={fullAccess}
|
||||
warning={permissionColor !== undefined}
|
||||
icon={permissionIcon}
|
||||
accessibleName={
|
||||
model
|
||||
? `Model, ${secondary ? `${primary} ${secondary}` : primary}${fullAccess ? ', full access' : ''}`
|
||||
? `Model, ${secondary ? `${primary} ${secondary}` : primary}${permissionColor ? ', full access' : ''}`
|
||||
: `Permissions, ${primary}`
|
||||
}
|
||||
disabled={busy}
|
||||
disabled={busy || (!model && permission?.settable === false)}
|
||||
onPress={() => {
|
||||
onDrawerOpen(home.id)
|
||||
Keyboard.dismiss()
|
||||
@@ -304,7 +309,7 @@ function OptionRows({
|
||||
<View style={[styles.group, styles.groupSpaced]}>
|
||||
{descriptors.map((descriptor, index) => {
|
||||
const isPermission = descriptor === permission
|
||||
const disabled = isPermission ? busy : locked
|
||||
const disabled = isPermission ? busy || !descriptor.settable : locked
|
||||
if (descriptor.kind.type === 'boolean' && !descriptor.action) {
|
||||
return (
|
||||
<View key={descriptor.id} style={index < descriptors.length - 1 && styles.divided}>
|
||||
|
||||
@@ -26,6 +26,9 @@ function selectedChoiceLabel(descriptor: SessionOptionDescriptor): string | null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (descriptor.id === 'permissionMode' && descriptor.description) {
|
||||
return descriptor.description
|
||||
}
|
||||
const current = descriptor.kind.currentValue
|
||||
const choice: SessionOptionSelectChoice = descriptor.kind.choices.find(
|
||||
(candidate) => candidate.value === current
|
||||
@@ -44,7 +47,7 @@ export function mobileSessionOptionSummaryValue(descriptor: SessionOptionDescrip
|
||||
return descriptor.kind.currentValue ? 'On' : 'Off'
|
||||
}
|
||||
if (descriptor.valueSource === 'unknown') {
|
||||
return 'Not set'
|
||||
return descriptor.id === 'permissionMode' ? 'Permissions' : 'Not set'
|
||||
}
|
||||
return selectedChoiceLabel(descriptor) ?? 'Not set'
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('structuredAgentSessionHostSupport', () => {
|
||||
sendOptions: false,
|
||||
conversationStop: false,
|
||||
permissionModes: false,
|
||||
permissionOwn: false,
|
||||
itemDetail: false,
|
||||
phoneAttachments: false
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities'
|
||||
import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability'
|
||||
import {
|
||||
AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/agent-session-permission-modes-capability'
|
||||
import { AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-runtime-capabilities'
|
||||
|
||||
export type StructuredAgentSessionHostSupport = {
|
||||
@@ -35,6 +38,7 @@ export type StructuredAgentSessionHostSupport = {
|
||||
conversationStop: boolean
|
||||
/** The host applies each chat's permission mode; 1.4.225–1.4.226 do without advertising it. */
|
||||
permissionModes: boolean
|
||||
permissionOwn?: boolean
|
||||
itemDetail: boolean
|
||||
phoneAttachments: boolean
|
||||
}
|
||||
@@ -56,6 +60,7 @@ export function structuredAgentSessionHostSupport(
|
||||
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY),
|
||||
sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY),
|
||||
conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY),
|
||||
permissionOwn: capabilities.includes(AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY),
|
||||
permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY),
|
||||
itemDetail: capabilities.includes(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY),
|
||||
phoneAttachments: capabilities.includes(AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY)
|
||||
|
||||
@@ -58,7 +58,7 @@ async function mount(initial: Props) {
|
||||
enabled: true,
|
||||
fence: props.fence,
|
||||
mutate,
|
||||
hostSupport: { permissionModes: true, coherentModelCatalog: true }
|
||||
hostSupport: { permissionModes: true, coherentModelCatalog: true, statusFeed: false }
|
||||
})
|
||||
latest = options
|
||||
frames.push(modelChoices(options.optionSnapshot))
|
||||
|
||||
@@ -50,6 +50,7 @@ type ProbeProps = {
|
||||
optionStamp: string
|
||||
agent?: string
|
||||
hostAdvertisesPermissionModes?: boolean | null
|
||||
permissionOwn?: boolean
|
||||
}
|
||||
|
||||
const mutate: StructuredAgentSessionMutate = vi.fn()
|
||||
@@ -68,7 +69,11 @@ async function mountOptions(props: ProbeProps) {
|
||||
hostSupport:
|
||||
probe.hostAdvertisesPermissionModes === null
|
||||
? null
|
||||
: { permissionModes: probe.hostAdvertisesPermissionModes ?? true }
|
||||
: {
|
||||
permissionModes: probe.hostAdvertisesPermissionModes ?? true,
|
||||
permissionOwn: probe.permissionOwn,
|
||||
statusFeed: false
|
||||
}
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -150,6 +155,42 @@ describe('the permission pill against a host predating per-chat modes', () => {
|
||||
})
|
||||
|
||||
describe('the permission mode on the phone', () => {
|
||||
it('writes only a concrete per-chat choice even when it matches the report for Own', async () => {
|
||||
vi.mocked(mutate).mockReset()
|
||||
vi.mocked(mutate).mockResolvedValue({
|
||||
status: 'accepted',
|
||||
value: { options: { permissionMode: 'full-access' } },
|
||||
sameFence: true
|
||||
})
|
||||
const client: RpcClient = {
|
||||
...optionsClient().client,
|
||||
sendRequest: async () => ({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {
|
||||
...optionsFor('own'),
|
||||
current: {
|
||||
model: 'gpt-live',
|
||||
permissionMode: 'own',
|
||||
reportedPermissionMode: 'full-access'
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
const options = await mountOptions({ client, fence: 1, optionStamp: '', permissionOwn: true })
|
||||
expect(options.permission()).toBe('full-access')
|
||||
expect(options.offered()).not.toContain('own')
|
||||
await options.pick('full-access')
|
||||
expect(mutate).toHaveBeenCalledExactlyOnceWith(
|
||||
'agentSession.setOption',
|
||||
'agentSession.setOption',
|
||||
{ key: 'permissionMode', value: 'full-access' }
|
||||
)
|
||||
await options.unmount()
|
||||
vi.mocked(mutate).mockReset()
|
||||
})
|
||||
|
||||
it('re-reads once when the host status moves, so a desktop pick shows here', async () => {
|
||||
const rpc = optionsClient('ask', 'full-access')
|
||||
const options = await mountOptions({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement } from 'react'
|
||||
import { createElement, useLayoutEffect } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
@@ -144,18 +144,17 @@ type ProbeProps = {
|
||||
}
|
||||
|
||||
function Probe(props: ProbeProps): null {
|
||||
props.onRender(
|
||||
useMobileStructuredAgentOptions({
|
||||
agent: props.agent,
|
||||
client: props.client,
|
||||
sessionId: props.sessionId,
|
||||
enabled: true,
|
||||
fence: props.fence,
|
||||
mutate: props.mutate,
|
||||
...(props.optionStamp ? { optionStamp: props.optionStamp } : {}),
|
||||
hostSupport: { permissionModes: true }
|
||||
})
|
||||
)
|
||||
const controller = useMobileStructuredAgentOptions({
|
||||
agent: props.agent,
|
||||
client: props.client,
|
||||
sessionId: props.sessionId,
|
||||
enabled: true,
|
||||
fence: props.fence,
|
||||
mutate: props.mutate,
|
||||
...(props.optionStamp ? { optionStamp: props.optionStamp } : {}),
|
||||
hostSupport: { permissionModes: true, statusFeed: false }
|
||||
})
|
||||
useLayoutEffect(() => props.onRender(controller), [controller, props.onRender])
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export function useMobileStructuredAgentOptions(args: {
|
||||
optionStamp?: string
|
||||
hostSupport: Pick<
|
||||
StructuredAgentSessionHostSupport,
|
||||
'permissionModes' | 'coherentModelCatalog'
|
||||
'permissionModes' | 'permissionOwn' | 'coherentModelCatalog' | 'statusFeed'
|
||||
> | null
|
||||
}) {
|
||||
const { agent, client, enabled, fence, mutate, optionStamp, sessionId, hostSupport } = args
|
||||
@@ -56,6 +56,7 @@ export function useMobileStructuredAgentOptions(args: {
|
||||
[sessionId]
|
||||
)
|
||||
const {
|
||||
projectPermission,
|
||||
optionState,
|
||||
optionStateRef,
|
||||
activeOptionRecordRef,
|
||||
@@ -72,6 +73,7 @@ export function useMobileStructuredAgentOptions(args: {
|
||||
} = useMobileStructuredOptionState({
|
||||
agent,
|
||||
enabled,
|
||||
statusFeed: hostSupport?.statusFeed === true,
|
||||
fence,
|
||||
optionCatalog,
|
||||
sessionId,
|
||||
@@ -111,16 +113,29 @@ export function useMobileStructuredAgentOptions(args: {
|
||||
useLayoutEffect(() => {
|
||||
lockSnapshotRef.current = lockSnapshot
|
||||
})
|
||||
const shown = lockSnapshot(structuredAgentSessionShownSnapshot(optionState))
|
||||
const shown = lockSnapshot(
|
||||
structuredAgentSessionShownSnapshot(
|
||||
projectPermission({
|
||||
...optionState,
|
||||
permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null
|
||||
})
|
||||
)
|
||||
)
|
||||
const optionSnapshot = legacyHostPermissions
|
||||
? [...shown, legacyHostPermissionDescriptor()]
|
||||
: shown
|
||||
|
||||
const setStructuredOption = useCallback(
|
||||
async (id: string, value: SessionOptionValue): Promise<boolean> => {
|
||||
const currentState = optionStateRef.current
|
||||
const currentState = projectPermission({
|
||||
...optionStateRef.current,
|
||||
permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null
|
||||
})
|
||||
const encoded = encodeStructuredAgentSessionOptionValue(id, value)
|
||||
if (
|
||||
(id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID &&
|
||||
value === 'own' &&
|
||||
!hostSupport?.permissionOwn) ||
|
||||
!isCurrentContext() ||
|
||||
pendingOptionRef.current !== null ||
|
||||
!client ||
|
||||
@@ -213,6 +228,8 @@ export function useMobileStructuredAgentOptions(args: {
|
||||
[
|
||||
agent,
|
||||
awaitingOptions,
|
||||
hostSupport?.permissionOwn,
|
||||
projectPermission,
|
||||
client,
|
||||
createdHere,
|
||||
mutate,
|
||||
@@ -234,6 +251,7 @@ export function useMobileStructuredAgentOptions(args: {
|
||||
demandModelCatalog,
|
||||
setStructuredOption,
|
||||
optionStateRef,
|
||||
projectPermission,
|
||||
lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot)
|
||||
})
|
||||
|
||||
|
||||
@@ -71,8 +71,13 @@ it('subscribes when an older host refuses the hold, and shows the transcript (P2
|
||||
onData(SNAPSHOT)
|
||||
return () => undefined
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an RPC client stub with the two members the hook calls.
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's request, subscription and connection members are provided.
|
||||
const client = {
|
||||
sendRequest,
|
||||
subscribe,
|
||||
getState: () => 'connected',
|
||||
onStateChange: () => () => {}
|
||||
} as unknown as RpcClient
|
||||
function Harness(): null {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's options, of which this test supplies the ones it reads.
|
||||
hook = useMobileStructuredAgentSession({
|
||||
|
||||
@@ -61,7 +61,9 @@ describe('useMobileStructuredAgentSession transcript', () => {
|
||||
|
||||
async function mount(agent: string, items: AgentJournalRenderItem[]) {
|
||||
let listener: ((value: unknown) => void) | null = null
|
||||
const client: Pick<RpcClient, 'sendRequest' | 'subscribe'> = {
|
||||
const client: Pick<RpcClient, 'sendRequest' | 'subscribe' | 'getState' | 'onStateChange'> = {
|
||||
getState: () => 'connected',
|
||||
onStateChange: () => () => {},
|
||||
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
|
||||
id: 'response-1',
|
||||
ok: true,
|
||||
@@ -78,7 +80,7 @@ describe('useMobileStructuredAgentSession transcript', () => {
|
||||
}
|
||||
function Harness(): null {
|
||||
rendered.hook = useMobileStructuredAgentSession({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's request, subscription and connection members are provided.
|
||||
client: client as RpcClient,
|
||||
sessionId: 'session-1',
|
||||
enabled: true,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createElement } from 'react'
|
||||
import { snapshotEvent as publishedSnapshot } from './use-mobile-structured-agent-session-queued.test-fixture'
|
||||
import {
|
||||
snapshotEvent as publishedSnapshot,
|
||||
fieldsOf,
|
||||
mutationOk,
|
||||
ok
|
||||
} from './use-mobile-structured-agent-session-queued.test-fixture'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalDispatchState,
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalResolution
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
@@ -28,10 +32,6 @@ function listedModelIds(snapshot: readonly SessionOptionDescriptor[]): string[]
|
||||
return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : []
|
||||
}
|
||||
|
||||
function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function snapshotEvent(fence = 3, items: AgentJournalRenderItem[] = []) {
|
||||
return publishedSnapshot({ fence, items })
|
||||
}
|
||||
@@ -121,19 +121,9 @@ function runningStatusItem(): AgentJournalRenderItem {
|
||||
}
|
||||
}
|
||||
|
||||
function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) {
|
||||
return ok({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 3,
|
||||
cursor: { epoch: 'epoch-1', sequence: 1 },
|
||||
value: structuredSendResultFixture(dispatchState, reason)
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultSendRequest(method: string, params?: Record<string, unknown>) {
|
||||
async function defaultSendRequest(method: string, params?: unknown) {
|
||||
if (method === 'agentSession.send') {
|
||||
return sendResult('accepted')
|
||||
return mutationOk(structuredSendResultFixture('accepted'))
|
||||
}
|
||||
if (method === 'agentSession.options') {
|
||||
return ok({
|
||||
@@ -185,11 +175,11 @@ async function defaultSendRequest(method: string, params?: Record<string, unknow
|
||||
fence: 3,
|
||||
cursor: { epoch: 'epoch-1', sequence: 3 },
|
||||
value: {
|
||||
itemId: String(params?.itemId ?? ''),
|
||||
itemId: String(fieldsOf(params).itemId ?? ''),
|
||||
revision: 2,
|
||||
resolution: {
|
||||
state: 'resolved',
|
||||
selectedOptionId: String(params?.optionId ?? ''),
|
||||
selectedOptionId: String(fieldsOf(params).optionId ?? ''),
|
||||
resolvedBy: 'mobile',
|
||||
resolvedAt: 123
|
||||
}
|
||||
@@ -210,10 +200,17 @@ describe('useMobileStructuredAgentSession', () => {
|
||||
listener = onData
|
||||
return unsubscribe
|
||||
})
|
||||
const client = {
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe
|
||||
} as unknown as RpcClient
|
||||
subscribe,
|
||||
getState: () => 'connected',
|
||||
onStateChange: () => () => {},
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
updateTerminalSubscriptionViewport: () => {},
|
||||
notifyForeground: () => {},
|
||||
close: () => {}
|
||||
}
|
||||
let storedOperations: Map<string, string>
|
||||
|
||||
function Harness({
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed'
|
||||
import { projectRuntimePermission } from '../../../src/shared/structured-agent-session-permission-option'
|
||||
import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session/agent-session-conversation-command'
|
||||
import type {
|
||||
AgentSessionOptionsResult,
|
||||
@@ -7,7 +10,6 @@ import { callAgentSession } from './mobile-structured-agent-session-rpc'
|
||||
import { forgetMobileCreatedStructuredSession } from './mobile-created-structured-sessions'
|
||||
import { createCoalescingKeyedRunner } from '../../../src/shared/coalescing-keyed-runner'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
|
||||
import { usesInstalledAgentModelCatalog } from '../../../src/shared/agent-model-catalog-ownership'
|
||||
import { cloneStructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state'
|
||||
@@ -27,6 +29,7 @@ export function useMobileStructuredOptionState(args: {
|
||||
coherentCatalog?: boolean
|
||||
agent: string | null
|
||||
enabled: boolean
|
||||
statusFeed: boolean
|
||||
fence: number | null
|
||||
optionCatalog: AgentSessionOptionCatalog | null
|
||||
sessionId: string | null
|
||||
@@ -34,6 +37,7 @@ export function useMobileStructuredOptionState(args: {
|
||||
const {
|
||||
agent,
|
||||
enabled,
|
||||
statusFeed,
|
||||
fence,
|
||||
optionCatalog,
|
||||
sessionId,
|
||||
@@ -43,6 +47,19 @@ export function useMobileStructuredOptionState(args: {
|
||||
worktree,
|
||||
coherentCatalog: hostCoherentCatalog = false
|
||||
} = args
|
||||
const connected = useSyncExternalStore(
|
||||
client?.onStateChange ?? (() => () => {}),
|
||||
() => client?.getState() === 'connected'
|
||||
)
|
||||
const feed =
|
||||
enabled && connected && statusFeed && client && sessionId
|
||||
? mobileStructuredSessionStatusFeed(client)
|
||||
: null
|
||||
const summary = useSyncExternalStore(feed?.subscribe ?? (() => () => {}), () =>
|
||||
sessionId ? feed?.getSnapshot().get(sessionId) : undefined
|
||||
)
|
||||
const projectPermission = <T extends { reportedPermissionMode?: string | null }>(state: T): T =>
|
||||
projectRuntimePermission(state, summary, fence)
|
||||
const coherentCatalog = usesInstalledAgentModelCatalog(agent ?? '', hostCoherentCatalog)
|
||||
const [optionState, setOptionState] = useState(() =>
|
||||
createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog)
|
||||
@@ -59,7 +76,7 @@ export function useMobileStructuredOptionState(args: {
|
||||
},
|
||||
[]
|
||||
)
|
||||
const contextKey = JSON.stringify([agent, sessionId, newLaunch, worktree, fence])
|
||||
const contextKey = JSON.stringify([agent, sessionId, newLaunch, worktree, fence, connected])
|
||||
const contextRef = useRef({ key: contextKey, client })
|
||||
useEffect(() => {
|
||||
contextRef.current = { key: contextKey, client }
|
||||
@@ -83,7 +100,8 @@ export function useMobileStructuredOptionState(args: {
|
||||
...previous,
|
||||
catalogSource: 'host' as const,
|
||||
record: cloneStructuredAgentSessionOptionRecord(previous.record),
|
||||
pendingId: null
|
||||
pendingId: null,
|
||||
reportedPermissionMode: undefined
|
||||
}
|
||||
: seeded
|
||||
// Keep the chat’s mode visible through provider restarts.
|
||||
@@ -192,6 +210,7 @@ export function useMobileStructuredOptionState(args: {
|
||||
])
|
||||
|
||||
return {
|
||||
projectPermission,
|
||||
conversationSupport,
|
||||
imageSupport,
|
||||
awaitingOptions,
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useMobileStructuredOptionSurface(args: {
|
||||
optionSnapshot: SessionOptionDescriptor[]
|
||||
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
|
||||
optionStateRef: RefObject<StructuredAgentSessionOptionState>
|
||||
projectPermission: (state: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
|
||||
}) {
|
||||
const { optionSnapshot, optionStateRef, setStructuredOption, demandModelCatalog } = args
|
||||
const [optionPickerRequest, setOptionPickerRequest] = useState<{
|
||||
@@ -35,7 +36,9 @@ export function useMobileStructuredOptionSurface(args: {
|
||||
async (id: string, value: SessionOptionValue) => {
|
||||
await setStructuredOption(id, value)
|
||||
return {
|
||||
snapshot: args.lockSnapshot(structuredAgentSessionShownSnapshot(optionStateRef.current))
|
||||
snapshot: args.lockSnapshot(
|
||||
structuredAgentSessionShownSnapshot(args.projectPermission(optionStateRef.current))
|
||||
)
|
||||
}
|
||||
},
|
||||
[args.lockSnapshot, optionStateRef, setStructuredOption]
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AgentSessionTurnActivity
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
function journalItem(
|
||||
@@ -29,8 +30,9 @@ describe('useMobileStructuredAgentSession turn indicator', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
|
||||
let listener: ((value: unknown) => void) | null = null
|
||||
type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } }
|
||||
type RpcReply = RpcResponse
|
||||
const sendRequest = vi.fn(async (method: string): Promise<RpcReply> => ({
|
||||
id: 'indicator-rpc',
|
||||
ok: true,
|
||||
result:
|
||||
method === 'agentSession.options'
|
||||
@@ -45,8 +47,19 @@ describe('useMobileStructuredAgentSession turn indicator', () => {
|
||||
listener = onData
|
||||
return vi.fn()
|
||||
})
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
// Keep callbacks stable so rerenders cannot release the session’s hold/subscription.
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe,
|
||||
getState: () => 'connected',
|
||||
onStateChange: () => () => {},
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
updateTerminalSubscriptionViewport: () => {},
|
||||
notifyForeground: () => {},
|
||||
close: () => {}
|
||||
}
|
||||
// Stable across renders: a fresh callback would re-run the hold/subscribe effect
|
||||
// and release the session out from under the test.
|
||||
const onSendError = vi.fn()
|
||||
|
||||
function Harness(): null {
|
||||
@@ -163,7 +176,7 @@ describe('useMobileStructuredAgentSession turn indicator', () => {
|
||||
|
||||
await act(async () => {
|
||||
const cancelled = { ok: true, value: { cancelled: true } }
|
||||
answer({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } })
|
||||
answer({ id: 'indicator-rpc', ok: true, result: cancelled, _meta: { runtimeId: 'r1' } })
|
||||
})
|
||||
await vi.waitFor(() => expect(hook?.turnIndicator.stopping).toBe(false))
|
||||
})
|
||||
|
||||
@@ -148,6 +148,9 @@ export async function acquireAcpStructuredSession(input: {
|
||||
onExit: () => input.onExit(session)
|
||||
}
|
||||
)
|
||||
if (!spec.permissionModeConfig) {
|
||||
options.applied.set('permissionMode', launch.fullAccess ? 'full-access' : 'ask')
|
||||
}
|
||||
// From here, cancellation closes this connection even before exit is proven.
|
||||
input.track(connection)
|
||||
connection.subscribe((event: AcpSessionEvent) =>
|
||||
@@ -172,7 +175,7 @@ export async function acquireAcpStructuredSession(input: {
|
||||
sessionId,
|
||||
fence: acquire.fence,
|
||||
acquisitionGeneration: generation,
|
||||
reportedOptions: options.read().current
|
||||
reportedOptions: options.observedCurrent(spec.permissionModeConfig?.values)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join } from 'node:path'
|
||||
import { vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
|
||||
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract'
|
||||
import {
|
||||
@@ -65,7 +65,7 @@ export type AcpAdapterRig = {
|
||||
signal?: AbortSignal
|
||||
waitForStart?: boolean
|
||||
options?: Readonly<Record<string, string>>
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
permissionMode?: AgentChatPermissionChoice
|
||||
}): ReturnType<AcpStructuredSessionAdapter['acquire']>
|
||||
sent(method: string): FakeFrame[]
|
||||
frame(method: string, index?: number): Promise<FakeFrame>
|
||||
|
||||
@@ -33,6 +33,14 @@ const result = (frame: AcpFixtureFrame | undefined) =>
|
||||
z.record(z.string(), z.unknown()).parse(frame?.message.result)
|
||||
afterEach(closeProviderTimelineRigs)
|
||||
|
||||
it('own settings keep the agent’s reported mode without sending a mode config', async () => {
|
||||
const rig = await openAcpAdapterRig({ spec: ANTIGRAVITY })
|
||||
await rig.acquire({ permissionMode: 'own' })
|
||||
expect(rig.sent('session/set_config_option')).toHaveLength(0)
|
||||
const options = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 })
|
||||
expect(options.current.reportedPermissionMode).toBe(null)
|
||||
})
|
||||
|
||||
it('signs a first chat in, asks in default mode, and answers a question with one of its choices', async () => {
|
||||
const [initialized, opened, ...turn] = await readAcpFixture('antigravity-v1-question')
|
||||
const replay = new GrokFixtureReplay(turn)
|
||||
|
||||
@@ -129,7 +129,7 @@ export async function initializeAcpStructuredSession(
|
||||
options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded')
|
||||
liveLane.apply(liveLane.translator.contextModels(started.response.models, now()))
|
||||
const mode = spec.permissionModeConfig
|
||||
if (mode) {
|
||||
if (mode && acquire.permissionMode !== 'own') {
|
||||
// Fail startup rather than silently use a different permission mode.
|
||||
const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask
|
||||
const configId = options.modeConfigId()
|
||||
@@ -193,7 +193,7 @@ export async function initializeAcpStructuredSession(
|
||||
}
|
||||
return {
|
||||
link,
|
||||
reportedOptions: options.read().current,
|
||||
reportedOptions: options.observedCurrent(spec.permissionModeConfig?.values),
|
||||
catalogListing: options.startListing()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AcpStructuredOptions } from './acp-structured-options'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AcpStructuredOptions, readAcpLiveOptions } from './acp-structured-options'
|
||||
import { grokModelEfforts } from './acp-dialects/grok-model-catalog'
|
||||
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
|
||||
import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect'
|
||||
import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect'
|
||||
@@ -31,6 +32,78 @@ const configOptions = [
|
||||
]
|
||||
|
||||
describe('ACP session options', () => {
|
||||
it.each([10, 100])(
|
||||
'projects %i live models once while retaining current/report values',
|
||||
async (count) => {
|
||||
const modelEfforts = vi.fn(grokModelEfforts)
|
||||
const options = new AcpStructuredOptions({ ...GROK_ACP_DIALECT, modelEfforts })
|
||||
options.adoptSession({
|
||||
configOptions: [
|
||||
...configOptions.slice(1),
|
||||
{
|
||||
id: 'permission',
|
||||
name: 'Permission',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: 'default',
|
||||
options: [{ value: 'default', name: 'Default' }]
|
||||
}
|
||||
],
|
||||
models: {
|
||||
currentModelId: 'model-0',
|
||||
availableModels: Array.from({ length: count }, (_, index) => ({
|
||||
modelId: `model-${index}`,
|
||||
name: `Model ${index}`,
|
||||
_meta: {
|
||||
supportsReasoningEffort: true,
|
||||
reasoningEfforts: [{ value: 'medium', default: true }]
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
const read = vi.spyOn(options, 'read')
|
||||
const session = {
|
||||
options,
|
||||
connection: {
|
||||
initialize: async () => ({
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: { promptCapabilities: { image: true } }
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const mapping of [{ ask: 'default' }, undefined]) {
|
||||
read.mockClear()
|
||||
modelEfforts.mockClear()
|
||||
const result = await readAcpLiveOptions(session, mapping)
|
||||
expect({
|
||||
reads: read.mock.calls.length,
|
||||
modelProjections: modelEfforts.mock.calls.length
|
||||
}).toEqual({
|
||||
reads: 1,
|
||||
modelProjections: count
|
||||
})
|
||||
expect(result.current).toEqual({
|
||||
model: 'model-0',
|
||||
effort: 'high',
|
||||
confirmed: ['model', 'effort'],
|
||||
reportedPermissionMode: mapping ? 'ask' : null
|
||||
})
|
||||
expect(result.models).toHaveLength(count)
|
||||
expect(
|
||||
result.models.every(
|
||||
(model) =>
|
||||
!model.isDefault &&
|
||||
model.defaultEffort === 'medium' &&
|
||||
model.efforts?.[0]?.value === 'medium'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(result.catalogListing.models).toBe(result.models)
|
||||
expect(result.catalogListing.configuredDefault).toBeUndefined()
|
||||
expect(result.acceptsImages).toBe(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the session’s picks out of the catalog facts', () => {
|
||||
// OpenCode refuses an effort the selected model lacks, so another model's empty menu stays strict.
|
||||
const options = new AcpStructuredOptions(OPENCODE_ACP_DIALECT)
|
||||
|
||||
@@ -63,7 +63,7 @@ export class AcpStructuredOptions {
|
||||
// Only new sessions resolve configured defaults; loaded sessions retain their saved choices.
|
||||
private resolvesConfig = false
|
||||
readonly picked = new Set<string>()
|
||||
/** Last successful Orca writes, distinct from agent-reported values. */
|
||||
/** Installed Orca policy and successful writes, distinct from native agent reports. */
|
||||
readonly applied = new Map<string, string>()
|
||||
private initialChoice: Readonly<Record<string, string>> = {}
|
||||
currentProof = new Set<string>()
|
||||
@@ -209,6 +209,22 @@ export class AcpStructuredOptions {
|
||||
}
|
||||
}
|
||||
|
||||
observedCurrent(values?: Readonly<Partial<Record<string, string>>>) {
|
||||
return { ...this.read().current, reportedPermissionMode: this.reportedPermissionMode(values) }
|
||||
}
|
||||
|
||||
reportedPermissionMode(
|
||||
values: Readonly<Partial<Record<string, string>>> | undefined
|
||||
): string | null {
|
||||
if (!values) {
|
||||
return this.applied.get('permissionMode') ?? null
|
||||
}
|
||||
const current = this.select('mode')?.currentValue
|
||||
return current
|
||||
? (Object.entries(values).find(([, value]) => value === current)?.[0] ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
// Match the mode category rather than assuming an agent's config ID.
|
||||
modeConfigId(): string {
|
||||
return this.select('mode')?.id ?? 'mode'
|
||||
@@ -231,11 +247,20 @@ export async function acpSessionAcceptsImages(session: {
|
||||
}
|
||||
|
||||
export async function readAcpLiveOptions(
|
||||
session: Parameters<typeof acpSessionAcceptsImages>[0] & { options: AcpStructuredOptions }
|
||||
session: Parameters<typeof acpSessionAcceptsImages>[0] & { options: AcpStructuredOptions },
|
||||
permissionModes?: Readonly<Partial<Record<string, string>>>
|
||||
) {
|
||||
const acceptsImages = await acpSessionAcceptsImages(session)
|
||||
// Only startup establishes config resolution; later reads may reflect changed picks.
|
||||
return { ...withLiveCatalogListing(session.options.read()), acceptsImages }
|
||||
const options = withLiveCatalogListing(session.options.read())
|
||||
return {
|
||||
...options,
|
||||
current: {
|
||||
...options.current,
|
||||
reportedPermissionMode: session.options.reportedPermissionMode(permissionModes)
|
||||
},
|
||||
acceptsImages
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the current, still-awaited write may establish reusable option proof. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { acpLaunchSpecFor } from './acp-launch-specs'
|
||||
import { z } from 'zod'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
@@ -16,6 +17,51 @@ afterEach(closeProviderTimelineRigs)
|
||||
const configParams = z.object({ configId: z.string(), value: z.string() })
|
||||
|
||||
describe('ACP child published before its handshake', () => {
|
||||
it('publishes mapped initial/config permission without selecting it, and generic unknown for legacy modes', async () => {
|
||||
const mode = {
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: 'default',
|
||||
options: [
|
||||
{ value: 'default', name: 'Default' },
|
||||
{ value: 'yolo', name: 'Yolo' }
|
||||
]
|
||||
}
|
||||
const rig = await openAcpAdapterRig({
|
||||
spec: acpLaunchSpecFor('antigravity')!,
|
||||
script: (agent) =>
|
||||
agent.on('session/new', (frame) =>
|
||||
agent.reply(frame, {
|
||||
sessionId: PROVIDER_SESSION,
|
||||
configOptions: [...GROK_CONFIG_OPTIONS, mode]
|
||||
})
|
||||
)
|
||||
})
|
||||
await rig.acquire({ permissionMode: 'own' })
|
||||
expect(rig.started().reportedOptions).toMatchObject({ reportedPermissionMode: 'ask' })
|
||||
for (const [currentValue, expected] of [
|
||||
['yolo', 'full-access'],
|
||||
['unknown', null]
|
||||
] as const) {
|
||||
rig.child().agent.notify('session/update', {
|
||||
sessionId: PROVIDER_SESSION,
|
||||
update: {
|
||||
sessionUpdate: 'config_option_update',
|
||||
configOptions: [...GROK_CONFIG_OPTIONS, { ...mode, currentValue }]
|
||||
}
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(rig.lifecycle.at(-1)).toMatchObject({
|
||||
type: 'options-reported',
|
||||
reportedOptions: { reportedPermissionMode: expected }
|
||||
})
|
||||
)
|
||||
}
|
||||
expect(rig.sent('session/set_config_option')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('opens without writing saved message choices to the provider', async () => {
|
||||
let answer: (() => void) | undefined
|
||||
const writes: string[] = []
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { AcpLaunchSpec } from './acp-launch-specs'
|
||||
import type { ConnectAcpAgent } from './acp-structured-connection'
|
||||
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
|
||||
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
|
||||
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
|
||||
|
||||
// Grace begins at cancellation, before process close.
|
||||
export const ACP_STOP_GRACE_MS = 4_000
|
||||
@@ -23,7 +23,7 @@ export type AcpStructuredSessionAdapterDeps = {
|
||||
identity: AgentSessionJournalIdentity
|
||||
spawnToken: string
|
||||
/** Defaults to ask. */
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
permissionMode?: AgentChatPermissionChoice
|
||||
}) => Promise<AcpStructuredLaunch>
|
||||
/** The same launch for a read that starts no child (a conversation copy): the agent's env, unsealed. */
|
||||
readLaunch: (identity: AgentSessionJournalIdentity) => Promise<AcpStructuredLaunch>
|
||||
|
||||
@@ -251,8 +251,10 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
|
||||
return session.options.reported()
|
||||
}
|
||||
|
||||
readOptions = async (input: { sessionId: string; fence: number }) =>
|
||||
readAcpLiveOptions(this.live(input.sessionId))
|
||||
readOptions = async (input: { sessionId: string; fence: number }) => {
|
||||
const session = this.live(input.sessionId)
|
||||
return readAcpLiveOptions(session, session.spec.permissionModeConfig?.values)
|
||||
}
|
||||
|
||||
readCommands = (sessionId: string) => {
|
||||
const session = this.children.sessions.get(sessionId)
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests'
|
||||
|
||||
@@ -124,7 +124,7 @@ function recordingSpawner(spawns: SpawnSeen[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedLaunch(permissionMode: AgentChatPermissionMode, launchArgs: string[] = []) {
|
||||
function resolvedLaunch(permissionMode: AgentChatPermissionChoice, launchArgs: string[] = []) {
|
||||
const record = {
|
||||
sessionId: 'contract-pin-session',
|
||||
provider: 'claude',
|
||||
@@ -316,6 +316,26 @@ describe('Claude Agent SDK contract pins', () => {
|
||||
expect(replayArgs.filter((arg) => arg === '--replay-user-messages')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves own-settings permission resolution to the CLI in the real SDK child argv', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const spawns: SpawnSeen[] = []
|
||||
const launch = await resolvedLaunch('own')
|
||||
await drainQuery({
|
||||
...launch.options,
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario),
|
||||
canUseTool: async () => ({ behavior: 'deny', message: 'unused' }),
|
||||
spawnClaudeCodeProcess: recordingSpawner(spawns)
|
||||
})
|
||||
|
||||
expect(spawns).toHaveLength(1)
|
||||
const argv = normalizeArgv(spawns[0]!.args)
|
||||
expect(argv).not.toContain('--permission-mode')
|
||||
expect(argv).not.toContain('--dangerously-skip-permissions')
|
||||
expect(launch.options.permissionMode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('produces a matching CLI flag for every pre-SDK argv entry', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const spawns: SpawnSeen[] = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentSessionAccountKind } from '../../shared/agent-session-availability'
|
||||
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
|
||||
import type { ClaudeAuthDiagnostic } from './claude-structured-session-state'
|
||||
import type { ClaudeAuthDiagnostic, ClaudeSession } from './claude-structured-session-state'
|
||||
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
|
||||
export type ClaudeInitObservation = {
|
||||
@@ -41,6 +41,46 @@ export function readClaudeInit(message: Record<string, unknown>): ClaudeInitObse
|
||||
: null
|
||||
}
|
||||
|
||||
/** Init and status frames describe the running mode; absent fields preserve the last report. */
|
||||
export function readClaudeReportedPermissionMode(
|
||||
message: Record<string, unknown>
|
||||
): string | null | undefined {
|
||||
if (
|
||||
message.type !== 'system' ||
|
||||
(message.subtype !== 'init' && message.subtype !== 'status') ||
|
||||
message.permissionMode === undefined
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
switch (message.permissionMode) {
|
||||
case 'default':
|
||||
return 'ask'
|
||||
case 'acceptEdits':
|
||||
return 'accept-edits'
|
||||
case 'auto':
|
||||
return 'auto'
|
||||
case 'plan':
|
||||
return 'plan'
|
||||
case 'bypassPermissions':
|
||||
return 'full-access'
|
||||
case null:
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function observeClaudeReportedPermissionMode(
|
||||
reports: ClaudeSession['reportedOptions'],
|
||||
message: Record<string, unknown>
|
||||
): boolean {
|
||||
const mode = readClaudeReportedPermissionMode(message)
|
||||
if (mode === undefined || mode === reports.reportedPermissionMode) {
|
||||
return false
|
||||
}
|
||||
reports.reportedPermissionMode = mode
|
||||
return true
|
||||
}
|
||||
|
||||
export function readClaudeModels(initialization: unknown): unknown[] {
|
||||
return isRecord(initialization) && Array.isArray(initialization.models)
|
||||
? initialization.models
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
Options as ClaudeAgentSdkOptions,
|
||||
PermissionMode
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
import type {
|
||||
AgentChatPermissionChoice,
|
||||
AgentChatPermissionMode
|
||||
} from '../../shared/agent-chat-permission-mode'
|
||||
import {
|
||||
agentSessionProviderHandleKey,
|
||||
agentSessionProviderHandleRoot
|
||||
@@ -39,25 +42,28 @@ export { claudeSessionIdForOrcaSession }
|
||||
export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const
|
||||
export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS'
|
||||
|
||||
type ClaudePermissionOptions = Pick<ClaudeAgentSdkOptions, 'permissionMode' | 'extraArgs'> & {
|
||||
resolvePermissionModeInCli?: true
|
||||
}
|
||||
|
||||
export type ClaudeStructuredSdkOptions = Pick<
|
||||
ClaudeAgentSdkOptions,
|
||||
| 'includePartialMessages'
|
||||
| 'systemPrompt'
|
||||
| 'settingSources'
|
||||
| 'supportedDialogKinds'
|
||||
| 'extraArgs'
|
||||
| 'additionalDirectories'
|
||||
| 'plugins'
|
||||
| 'model'
|
||||
| 'effort'
|
||||
| 'permissionMode'
|
||||
| 'allowDangerouslySkipPermissions'
|
||||
| 'settings'
|
||||
| 'sessionId'
|
||||
| 'resume'
|
||||
| 'resumeSessionAt'
|
||||
| 'forkSession'
|
||||
>
|
||||
> &
|
||||
ClaudePermissionOptions
|
||||
|
||||
/** Replay acknowledgements require an explicit flag; the SDK supplies the other transport flags. */
|
||||
export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = {
|
||||
@@ -78,8 +84,12 @@ const CLAUDE_PERMISSION_MODES = {
|
||||
|
||||
/** Full access is the owned CLI flag: the SDK's typed bypass emits a flag older binaries reject. */
|
||||
export function claudeStructuredPermissionOptions(
|
||||
mode: AgentChatPermissionMode
|
||||
): Pick<ClaudeStructuredSdkOptions, 'permissionMode' | 'extraArgs'> {
|
||||
mode: AgentChatPermissionChoice
|
||||
): ClaudePermissionOptions {
|
||||
if (mode === 'own') {
|
||||
// upstream request: https://github.com/anthropics/claude-agent-sdk-typescript/issues/519
|
||||
return { resolvePermissionModeInCli: true }
|
||||
}
|
||||
return mode === 'full-access'
|
||||
? { extraArgs: { 'dangerously-skip-permissions': null } }
|
||||
: { permissionMode: CLAUDE_PERMISSION_MODES[mode] }
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
AgentSessionAcquisition,
|
||||
StructuredAgentSessionAcquireInput
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { openClaudeStreamJsonConnection } from './claude-stream-json-connection'
|
||||
import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control'
|
||||
import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution'
|
||||
@@ -11,6 +8,7 @@ import { settleClaudeTurnEndWaiters } from './claude-request-end-wait'
|
||||
import {
|
||||
createClaudeInitProof,
|
||||
readClaudeCapabilities,
|
||||
observeClaudeReportedPermissionMode,
|
||||
readClaudeFrameString,
|
||||
readClaudeInit
|
||||
} from './claude-structured-init-proof'
|
||||
@@ -23,11 +21,8 @@ import { observeClaudeFastModeFacts } from './claude-structured-session-options'
|
||||
import { createClaudeSessionPublication } from './claude-structured-session-publication'
|
||||
import {
|
||||
mintClaudeAcquisitionGeneration,
|
||||
type ClaudeAcquisitionRegistry,
|
||||
type ClaudeSession,
|
||||
type ClaudeSessionExit,
|
||||
type ClaudeStructuredSessionAdapterDeps,
|
||||
type ClaudeAcquireCallbacks,
|
||||
type ClaudeSessionAcquisitionInput,
|
||||
type ClaudeLateDispatchOutcome
|
||||
} from './claude-structured-session-state'
|
||||
import { resolveClaudeAcquisitionError } from './claude-structured-session-close'
|
||||
@@ -48,14 +43,7 @@ export async function acquireClaudeSession({
|
||||
acquisitions,
|
||||
exits,
|
||||
callbacks
|
||||
}: {
|
||||
input: StructuredAgentSessionAcquireInput
|
||||
deps: ClaudeStructuredSessionAdapterDeps
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
callbacks: ClaudeAcquireCallbacks
|
||||
}): Promise<AgentSessionAcquisition> {
|
||||
}: ClaudeSessionAcquisitionInput): Promise<AgentSessionAcquisition> {
|
||||
const sessionId = input.identity.sessionId
|
||||
const prompts = new ClaudePromptRegistry()
|
||||
const { previous, attempt } = acquisitions.start(sessionId, prompts)
|
||||
@@ -82,6 +70,7 @@ export async function acquireClaudeSession({
|
||||
)
|
||||
const contextActivity = translator?.contextActivity ?? 0
|
||||
const recordTurnEnd = claudeTurnResumePointWriter(sessionId, sessions, deps)
|
||||
const observedOptions: ClaudeSession['reportedOptions'] = {}
|
||||
const onMessage = (message: Record<string, unknown>): void => {
|
||||
const init = readClaudeInit(message)
|
||||
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
|
||||
@@ -91,6 +80,7 @@ export async function acquireClaudeSession({
|
||||
}
|
||||
return
|
||||
}
|
||||
const permissionChanged = observeClaudeReportedPermissionMode(observedOptions, message)
|
||||
const modelChanged =
|
||||
liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false
|
||||
if (init) {
|
||||
@@ -130,13 +120,16 @@ export async function acquireClaudeSession({
|
||||
}
|
||||
try {
|
||||
callbacks.deliver(attempt, sessionId, () => {
|
||||
if (modelChanged && liveSession && init?.model) {
|
||||
if (liveSession && (modelChanged || permissionChanged)) {
|
||||
callbacks.emit(liveSession, input.events, {
|
||||
type: 'options-reported',
|
||||
sessionId,
|
||||
fence: liveSession.fence,
|
||||
acquisitionGeneration: liveSession.acquisitionGeneration,
|
||||
reportedOptions: { model: init.model }
|
||||
reportedOptions: {
|
||||
...(modelChanged && init?.model ? { model: init.model } : {}),
|
||||
reportedPermissionMode: observedOptions.reportedPermissionMode
|
||||
}
|
||||
})
|
||||
}
|
||||
callbacks.emit(liveSession, input.events, {
|
||||
@@ -278,6 +271,7 @@ export async function acquireClaudeSession({
|
||||
observedAt: deps.now?.() ?? Date.now()
|
||||
})
|
||||
const session = publication.session
|
||||
session.reportedOptions = observedOptions
|
||||
liveSession = session
|
||||
adoptClaudeStructuredSpawnOptions(session, launch.savedOptions)
|
||||
acquisitions.deleteIfCurrent(sessionId, attempt)
|
||||
|
||||
@@ -259,6 +259,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => {
|
||||
expect(options.current).toEqual({
|
||||
model: 'claude-sonnet-5',
|
||||
effort: 'high',
|
||||
reportedPermissionMode: undefined,
|
||||
confirmed: ['model', 'effort']
|
||||
})
|
||||
expect(options.models.some((model) => model.label === 'Default')).toBe(false)
|
||||
@@ -293,6 +294,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => {
|
||||
expect(result.current).toEqual({
|
||||
model: 'custom-model',
|
||||
effort: 'high',
|
||||
reportedPermissionMode: undefined,
|
||||
confirmed: ['model', 'effort']
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,6 +192,7 @@ export function claudeStructuredSessionOptionsFrom(
|
||||
...(support ? { fastModeSupport: support } : {}),
|
||||
current: {
|
||||
model,
|
||||
reportedPermissionMode: session.reportedOptions.reportedPermissionMode,
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode !== undefined ? { fastMode } : {}),
|
||||
...(session.fastModeState ? { fastModeState: session.fastModeState } : {}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
adapterAtPublishFor,
|
||||
fakeClaude,
|
||||
claudeFrame,
|
||||
identityFor,
|
||||
claudeOpened,
|
||||
USER_MESSAGE
|
||||
@@ -21,6 +22,96 @@ describe('Claude host-awaited open', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
// SDK-typed shape; ordering constructed
|
||||
it.each([
|
||||
['bypassPermissions', 'full-access'],
|
||||
['plan', 'plan'],
|
||||
[null, null],
|
||||
[undefined, 'accept-edits']
|
||||
] as const)(
|
||||
'preserves the startup status permission report %s over init',
|
||||
async (mode, expected) => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = adapterAtPublishFor({
|
||||
...claude,
|
||||
openConnection: async (...args) => {
|
||||
const connection = await claude.openConnection(...args)
|
||||
const observed = claude.connections[0]
|
||||
if (!observed) {
|
||||
throw new Error('missing test connection')
|
||||
}
|
||||
claudeFrame(observed, {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
permissionMode: 'acceptEdits',
|
||||
model: 'claude-sonnet-5'
|
||||
})
|
||||
claudeFrame(observed, {
|
||||
type: 'system',
|
||||
subtype: 'status',
|
||||
status: null,
|
||||
...(mode === undefined ? {} : { permissionMode: mode })
|
||||
})
|
||||
return connection
|
||||
}
|
||||
})
|
||||
await adapter.acquire({ ...ACQUIRE, options: { permissionMode: 'own' } })
|
||||
const opened = await claudeOpened(adapter, 'session-1')
|
||||
expect(opened.reportedOptions?.reportedPermissionMode).toBe(expected)
|
||||
expect(
|
||||
(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
|
||||
.reportedPermissionMode
|
||||
).toBe(expected)
|
||||
const connection = claude.connections[0]
|
||||
if (!connection) {
|
||||
throw new Error('missing test connection')
|
||||
}
|
||||
expect(connection.launch.options.permissionMode).toBeUndefined()
|
||||
expect(connection.calls.some((call) => call.subtype === 'set_permission_mode')).toBe(false)
|
||||
await adapter.closeAll()
|
||||
}
|
||||
)
|
||||
|
||||
it('refreshes only present owned permission reports through the existing options signal', async () => {
|
||||
const claude = fakeClaude()
|
||||
const { adapter, events } = adapterFor(claude)
|
||||
await adapter.acquire({ ...ACQUIRE, options: { permissionMode: 'own' } })
|
||||
await claudeOpened(adapter, 'session-1')
|
||||
const connection = claude.connections[0]
|
||||
if (!connection) {
|
||||
throw new Error('missing test connection')
|
||||
}
|
||||
for (const [frame, expected, changed] of [
|
||||
[
|
||||
{ subtype: 'init', permissionMode: 'acceptEdits', model: 'claude-haiku-4-5' },
|
||||
'accept-edits',
|
||||
true
|
||||
],
|
||||
[{ subtype: 'status', status: null, permissionMode: 'default' }, 'ask', true],
|
||||
[{ subtype: 'status', status: 'compacting' }, 'ask', false],
|
||||
[{ subtype: 'init', model: 'claude-haiku-4-5' }, 'ask', false],
|
||||
[{ subtype: 'status', status: null, permissionMode: 'plan' }, 'plan', true],
|
||||
[{ subtype: 'status', status: null, permissionMode: null }, null, true],
|
||||
[
|
||||
{ subtype: 'status', permissionMode: 'bypassPermissions', session_id: 'foreign' },
|
||||
null,
|
||||
false
|
||||
]
|
||||
] as const) {
|
||||
const before = events.filter((event) => event.type === 'options-reported').length
|
||||
claudeFrame(connection, { type: 'system', ...frame })
|
||||
expect(
|
||||
(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
|
||||
.reportedPermissionMode
|
||||
).toBe(expected)
|
||||
expect(events.filter((event) => event.type === 'options-reported').length - before).toBe(
|
||||
changed ? 1 : 0
|
||||
)
|
||||
}
|
||||
expect(connection.calls.some((call) => call.subtype === 'set_permission_mode')).toBe(false)
|
||||
await adapter.closeAll()
|
||||
})
|
||||
|
||||
it('publishes the owned process as starting before initialize answers', async () => {
|
||||
const claude = fakeClaude({ initDelayMs: 12_000 })
|
||||
const { adapter, events } = adapterFor(claude)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
claudeAuthDiagnostic,
|
||||
claudeInitializationAuthError,
|
||||
readClaudeCapabilities,
|
||||
readClaudeReportedPermissionMode,
|
||||
readClaudeModels,
|
||||
type ClaudeInitObservation,
|
||||
type ClaudeInitProof
|
||||
@@ -113,6 +114,10 @@ export async function readClaudeStartupFacts(input: {
|
||||
}
|
||||
|
||||
function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitializeFacts): void {
|
||||
const permission = facts.init ? readClaudeReportedPermissionMode(facts.init.message) : undefined
|
||||
if (permission !== undefined && session.reportedOptions.reportedPermissionMode === undefined) {
|
||||
session.reportedOptions.reportedPermissionMode = permission
|
||||
}
|
||||
const { init, initialization } = facts
|
||||
// Preserve a running model already reported by a turn's init frame.
|
||||
if (init?.model && session.reportedOptions.model === undefined) {
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-f
|
||||
import type { SubmissionRejectionFact } from '../../shared/agent-session-failure'
|
||||
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionOptionsReportedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
StructuredAgentSessionAcquireInput,
|
||||
StructuredAgentSessionOptionsReportedEvent
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
ClaudeStreamJsonConnection,
|
||||
openClaudeStreamJsonConnection
|
||||
@@ -159,7 +162,12 @@ export type ClaudeSession = {
|
||||
/** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */
|
||||
replayContentFallbackBlocked: boolean
|
||||
options: Map<string, string>
|
||||
reportedOptions: { model?: string; effort?: string; fastMode?: boolean }
|
||||
reportedOptions: {
|
||||
model?: string
|
||||
effort?: string
|
||||
fastMode?: boolean
|
||||
reportedPermissionMode?: string | null
|
||||
}
|
||||
/** CLI-resolved env/settings for the next request; lower priority than held options and turn reports. */
|
||||
appliedOptions?: { model?: string; effort?: string }
|
||||
/** CLI settings resolved before a turn changed them, when no launch override hid that value. */
|
||||
@@ -343,6 +351,15 @@ export async function cancelClaudeAcquisitionAttempt(
|
||||
})
|
||||
}
|
||||
|
||||
export type ClaudeSessionAcquisitionInput = {
|
||||
input: StructuredAgentSessionAcquireInput
|
||||
deps: ClaudeStructuredSessionAdapterDeps
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
callbacks: ClaudeAcquireCallbacks
|
||||
}
|
||||
|
||||
export type ClaudeAcquireCallbacks = {
|
||||
deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void
|
||||
emit: (
|
||||
|
||||
@@ -272,6 +272,15 @@ describe('codex structured launch resolution', () => {
|
||||
expect(launch.permissionPolicy).toEqual(policy)
|
||||
})
|
||||
|
||||
it('adds no policy overrides for the agent’s own settings', async () => {
|
||||
const launch = await resolverFor(record())({
|
||||
identity: IDENTITY,
|
||||
spawnToken: TEST_SPAWN_TOKEN,
|
||||
permissionMode: 'own'
|
||||
})
|
||||
expect(launch).not.toHaveProperty('permissionPolicy')
|
||||
})
|
||||
|
||||
it('ignores the bypass flag the agent Arguments carry', async () => {
|
||||
const launch = await resolverFor(record(), undefined, () => [
|
||||
'--dangerously-bypass-approvals-and-sandbox'
|
||||
|
||||
@@ -87,8 +87,10 @@ export function codexStructuredLaunchPart(
|
||||
resumeThreadId,
|
||||
// Only created threads may be unsaved; resumed, forked and adopted threads were persisted.
|
||||
...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}),
|
||||
// Omitted policy fields would inherit the resumed thread's previous permissions.
|
||||
permissionPolicy: codexStructuredPermissionPolicy(basis.permissionMode ?? 'ask'),
|
||||
// Own settings leaves a new thread to config and a resumed thread to its saved policy.
|
||||
...(basis.permissionMode === 'own'
|
||||
? {}
|
||||
: { permissionPolicy: codexStructuredPermissionPolicy(basis.permissionMode ?? 'ask') }),
|
||||
...(model ? { model } : {}),
|
||||
...(visuals ? { visuals } : {})
|
||||
},
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('Codex structured service tier without send-path catalog waits', () =>
|
||||
expect(codex.connections[0].calls.some((call) => call.method === 'thread/resume')).toBe(true)
|
||||
expect(codexStartReport(adapter).reportedOptions).toEqual({
|
||||
model: 'gpt-next',
|
||||
reportedPermissionMode: null,
|
||||
serviceTier: 'ultrafast'
|
||||
})
|
||||
await send(adapter, 'first')
|
||||
|
||||
@@ -75,6 +75,7 @@ export function reportedCodexThreadOptions(
|
||||
opened: CodexOpenedThread
|
||||
): CodexSession['reportedOptions'] {
|
||||
return {
|
||||
reportedPermissionMode: opened.reportedPermissionMode,
|
||||
...(opened.model ? { model: opened.model } : {}),
|
||||
...(opened.effort ? { effort: opened.effort } : {}),
|
||||
...('serviceTier' in opened
|
||||
|
||||
@@ -39,7 +39,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
|
||||
expect(codexStartReport(adapter).reportedOptions).toEqual({
|
||||
model: 'gpt-saved',
|
||||
effort: 'low',
|
||||
serviceTier: 'priority'
|
||||
serviceTier: 'priority',
|
||||
reportedPermissionMode: null
|
||||
})
|
||||
expect(codex.connections[0].calls.map((call) => call.method)).toEqual([
|
||||
'initialize',
|
||||
@@ -106,10 +107,11 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
|
||||
|
||||
expect(codexStartReport(adapter).reportedOptions).toEqual({
|
||||
model: 'gpt-live',
|
||||
effort: 'medium'
|
||||
effort: 'medium',
|
||||
reportedPermissionMode: null
|
||||
})
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({
|
||||
current: { model: 'gpt-live', effort: 'medium' },
|
||||
current: { model: 'gpt-live', effort: 'medium', reportedPermissionMode: null },
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-live',
|
||||
|
||||
@@ -39,6 +39,7 @@ export function reportedCodexSessionOptions(
|
||||
const serviceTier = session.options.get('serviceTier')
|
||||
return {
|
||||
model,
|
||||
reportedPermissionMode: session.reportedOptions.reportedPermissionMode,
|
||||
...(effort ? { effort } : {}),
|
||||
...(serviceTier ? { serviceTier } : {})
|
||||
}
|
||||
@@ -78,7 +79,14 @@ function composeLiveCodexCatalog(
|
||||
}
|
||||
: {})
|
||||
})
|
||||
return { ...catalog, fastModeSupport: listing.fastModeSupport }
|
||||
return {
|
||||
...catalog,
|
||||
current: {
|
||||
...catalog.current,
|
||||
reportedPermissionMode: session.reportedOptions.reportedPermissionMode
|
||||
},
|
||||
fastModeSupport: listing.fastModeSupport
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLiveCodexSessionOptions(
|
||||
|
||||
@@ -152,6 +152,7 @@ export type CodexSession = {
|
||||
prompts: CodexAcquisitionWindow['prompts']
|
||||
options: Map<string, string>
|
||||
reportedOptions: {
|
||||
reportedPermissionMode?: string | null
|
||||
model?: string
|
||||
effort?: string
|
||||
serviceTier?: string | null
|
||||
|
||||
@@ -14,6 +14,39 @@ function connectionFor(
|
||||
}
|
||||
|
||||
describe('openCodexThread', () => {
|
||||
it.each([
|
||||
['workspaceWrite', 'auto'],
|
||||
['readOnly', 'read-only'],
|
||||
['read-only', 'read-only']
|
||||
])('leaves own policy omitted on start/resume and reports %s', async (sandboxType, expected) => {
|
||||
const request = vi.fn(async () => ({
|
||||
thread: { id: 'own-thread' },
|
||||
approvalPolicy: 'on-request',
|
||||
sandbox: { type: sandboxType },
|
||||
approvalsReviewer: 'auto_review'
|
||||
}))
|
||||
for (const resumeThreadId of [null, 'own-thread']) {
|
||||
const opened = await openCodexThread(
|
||||
connectionFor(request),
|
||||
{ cwd: '/workspace', resumeThreadId },
|
||||
2_000
|
||||
)
|
||||
expect(opened.reportedPermissionMode).toBe(expected)
|
||||
}
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'thread/start',
|
||||
{ cwd: '/workspace' },
|
||||
{ timeoutMs: 2_000 }
|
||||
)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'thread/resume',
|
||||
{ cwd: '/workspace', threadId: 'own-thread', excludeTurns: true },
|
||||
{ timeoutMs: 2_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the resolved permission policy when starting and resuming a thread', async () => {
|
||||
const request = vi.fn(async (method: string) => ({
|
||||
thread: { id: method === 'thread/start' ? 'thread-created' : 'thread-existing' }
|
||||
@@ -97,6 +130,7 @@ describe('openCodexThread', () => {
|
||||
openCodexThread(connectionFor(standard), { cwd: '/workspace', resumeThreadId: null }, 2_000)
|
||||
).resolves.toEqual({
|
||||
threadId: 'thread-standard',
|
||||
reportedPermissionMode: null,
|
||||
thread: { id: 'thread-standard' },
|
||||
serviceTier: null
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readCodexThreadId } from './codex-structured-thread-facts'
|
||||
|
||||
export type CodexOpenedThread = {
|
||||
threadId: string
|
||||
reportedPermissionMode?: string | null
|
||||
supersededThreadId?: string
|
||||
thread?: Record<string, unknown>
|
||||
historyMode?: 'legacy' | 'paginated'
|
||||
@@ -130,6 +131,7 @@ export async function openCodexThread(
|
||||
const serviceTier = nonEmptyString(result.serviceTier)
|
||||
return {
|
||||
threadId,
|
||||
reportedPermissionMode: reportedCodexPermissionMode(result),
|
||||
...(supersededThreadId === undefined ? {} : { supersededThreadId }),
|
||||
thread,
|
||||
...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated'
|
||||
@@ -140,3 +142,28 @@ export async function openCodexThread(
|
||||
...(serviceTierKnown ? { serviceTier } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Unrecognized policies remain the agent's settings rather than a guessed Orca mode. */
|
||||
function reportedCodexPermissionMode(reply: Record<string, unknown>): string | null {
|
||||
const approval = reply.approvalPolicy ?? reply.approval_policy
|
||||
const reviewer = reply.approvalsReviewer ?? reply.approvals_reviewer
|
||||
const sandbox = reply.sandbox
|
||||
const sandboxType =
|
||||
typeof sandbox === 'object' && sandbox !== null && 'type' in sandbox ? sandbox.type : sandbox
|
||||
if (sandboxType === 'readOnly' || sandboxType === 'read-only') {
|
||||
return 'read-only'
|
||||
}
|
||||
if (
|
||||
(sandboxType === 'dangerFullAccess' || sandboxType === 'danger-full-access') &&
|
||||
approval === 'never'
|
||||
) {
|
||||
return 'full-access'
|
||||
}
|
||||
if (
|
||||
(sandboxType === 'workspaceWrite' || sandboxType === 'workspace-write') &&
|
||||
approval === 'on-request'
|
||||
) {
|
||||
return reviewer === 'auto_review' ? 'auto' : reviewer === 'user' ? 'ask' : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
|
||||
import {
|
||||
admitAttachOrRefuse,
|
||||
attachJournal,
|
||||
@@ -81,7 +81,7 @@ export type AttachFlowInput = {
|
||||
startupOptions?: Readonly<Record<string, string>>
|
||||
launchPermissionMode?: (
|
||||
record: AgentSessionRecord
|
||||
) => Promise<AgentChatPermissionMode | undefined>
|
||||
) => Promise<AgentChatPermissionChoice | undefined>
|
||||
/** Publish journal before new-owner sends; distinguish newly starting, retained live and resting owners. */
|
||||
onAttached: (
|
||||
attached: AttachedJournal,
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo
|
||||
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract'
|
||||
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
|
||||
|
||||
export type StructuredAgentSessionCaller = { callerKey: string }
|
||||
|
||||
@@ -69,7 +69,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider
|
||||
/** Immutable, memory-only opening send ID; distinguishes its restart from another start. */
|
||||
readonly startedFor?: string
|
||||
/** Launch-time permission mode, memory-only. */
|
||||
readonly permissionMode?: AgentChatPermissionMode
|
||||
readonly permissionMode?: AgentChatPermissionChoice
|
||||
/** Read adapter-held sends at each decision. */
|
||||
readonly heldSends?: () => ReadonlySet<string> | undefined
|
||||
readonly exitObservation?: DeferredStructuredAgentSessionEventSink['exitObservation']
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import {
|
||||
chatModelSupportsAutoMode,
|
||||
chatPermissionModeNeedsModelSupport,
|
||||
effectiveChatPermissionMode,
|
||||
type AgentChatPermissionMode
|
||||
type AgentChatPermissionChoice
|
||||
} from '../../../shared/agent-chat-permission-mode'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
|
||||
@@ -33,7 +33,7 @@ async function listedAutoModeSupport(
|
||||
export async function structuredAgentSessionLaunchPermission(
|
||||
deps: Pick<StructuredAgentSessionHostDeps, 'agents' | 'modelCatalog'>,
|
||||
record: AgentSessionRecord
|
||||
): Promise<AgentChatPermissionMode | null> {
|
||||
): Promise<AgentChatPermissionChoice | null> {
|
||||
const support = agentChatPermissionModes(record.provider)
|
||||
if (!support || !deps.agents.definition(record.provider)) {
|
||||
return null
|
||||
|
||||
@@ -23,7 +23,9 @@ function acceptsOptionPick(
|
||||
const definition = agents.definition(provider)
|
||||
return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
|
||||
? definition !== null &&
|
||||
agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true
|
||||
agentChatPermissionModes(provider) !== null &&
|
||||
(value === 'own' ||
|
||||
agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true)
|
||||
: definition?.restingOptions.acceptsKey(key) === true
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ export async function readStructuredAgentSessionOptions(
|
||||
const capabilities = agents.capabilities(agent)
|
||||
const floor = session.journal.context.floor()
|
||||
const contextFloor = floor ? { contextFloor: floor } : {}
|
||||
// Derive launch mode consistently; children never report it.
|
||||
// Intent and the live agent report stay separate: own never claims a mode before startup.
|
||||
const permissionMode = record
|
||||
? await structuredAgentSessionLaunchPermission(context.deps, record)
|
||||
: null
|
||||
@@ -289,7 +289,10 @@ export async function readStructuredAgentSessionOptions(
|
||||
confirmed,
|
||||
...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}),
|
||||
...(confirmed?.length ? { confirmed } : {}),
|
||||
...(permissionMode ? { permissionMode } : {})
|
||||
...(permissionMode ? { permissionMode } : {}),
|
||||
...(live && options.current.reportedPermissionMode !== undefined
|
||||
? { reportedPermissionMode: options.current.reportedPermissionMode }
|
||||
: { reportedPermissionMode: undefined })
|
||||
},
|
||||
rewind:
|
||||
phase === 'prepared' || phase === 'provider-succeeded'
|
||||
|
||||
+5
-5
@@ -16,7 +16,7 @@ import {
|
||||
} from './structured-agent-session-outgrown-child'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
|
||||
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types'
|
||||
import type {
|
||||
@@ -188,7 +188,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
|
||||
input: Parameters<typeof conversation>[0],
|
||||
work: Partial<typeof idle> = {},
|
||||
stopAgent = vi.fn(async () => undefined),
|
||||
launchPermission?: () => Promise<AgentChatPermissionMode | null>
|
||||
launchPermission?: () => Promise<AgentChatPermissionChoice | null>
|
||||
) => {
|
||||
const warn = vi.fn()
|
||||
await retireOutgrownStructuredAgentSessionChild('session', conversation(input), {
|
||||
@@ -250,7 +250,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
|
||||
})
|
||||
|
||||
describe('a child launched with another permission mode', () => {
|
||||
const now = (mode: AgentChatPermissionMode) => vi.fn(async () => mode)
|
||||
const now = (mode: AgentChatPermissionChoice) => vi.fn(async () => mode)
|
||||
const askChild = { child: childAt({ permissionMode: 'ask' }) }
|
||||
|
||||
it('is stopped once, and one launched with the current mode is kept', async () => {
|
||||
@@ -313,8 +313,8 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
|
||||
)
|
||||
}
|
||||
const stoppedAfterLaunch = async (
|
||||
launched: AgentChatPermissionMode,
|
||||
current: () => Promise<AgentChatPermissionMode | null>
|
||||
launched: AgentChatPermissionChoice,
|
||||
current: () => Promise<AgentChatPermissionChoice | null>
|
||||
) =>
|
||||
(await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current))
|
||||
.stopAgent.mock.calls.length > 0
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type StructuredAgentSessionChildWorkReads
|
||||
} from './structured-agent-session-idle-sweep'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
|
||||
|
||||
type OutgrownChildReading = Pick<StructuredAgentSessionHostSession, 'child'> & {
|
||||
journal: Pick<AgentSessionJournal, 'visitItemsWithLinkage' | 'itemFence' | 'newestTurn'> & {
|
||||
@@ -68,7 +68,7 @@ function owesWakeUp(
|
||||
/** Compare the actual launch mode so replacement cannot loop. */
|
||||
async function launchedWithOtherPermissionMode(
|
||||
child: OutgrownChildReading['child'],
|
||||
current: (() => Promise<AgentChatPermissionMode | null>) | undefined
|
||||
current: (() => Promise<AgentChatPermissionChoice | null>) | undefined
|
||||
): Promise<boolean> {
|
||||
if (!child || child.phase === 'starting' || child.close || !child.permissionMode || !current) {
|
||||
return false
|
||||
@@ -86,7 +86,7 @@ export async function retireOutgrownStructuredAgentSessionChild(
|
||||
work: StructuredAgentSessionChildWorkReads & {
|
||||
childWork: () => readonly AgentChildWorkView[] | undefined
|
||||
}
|
||||
launchPermission?: () => Promise<AgentChatPermissionMode | null>
|
||||
launchPermission?: () => Promise<AgentChatPermissionChoice | null>
|
||||
stopAgent: (sessionId: string) => Promise<void>
|
||||
logger: StructuredAgentSessionLogger
|
||||
}
|
||||
|
||||
+18
-2
@@ -15,7 +15,10 @@ import {
|
||||
saveStructuredAgentSessionStartReadbackListing
|
||||
} from './structured-agent-session-start-listing'
|
||||
import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt'
|
||||
import { noteStructuredAgentSessionReportedOptions } from './structured-agent-session-ran-options'
|
||||
import {
|
||||
noteStructuredAgentSessionRuntimePermission,
|
||||
noteStructuredAgentSessionReportedOptions
|
||||
} from './structured-agent-session-ran-options'
|
||||
import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts'
|
||||
import { recordAgentSessionProviderHandle } from '../../runtime/agent-session/ownership/agent-session-provider-handle-transition'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
@@ -82,6 +85,8 @@ export async function settleStructuredAgentSessionProviderStarted(
|
||||
return null
|
||||
}
|
||||
context.runtimeState.startupAttempts.ready(event.sessionId, child)
|
||||
noteStructuredAgentSessionRuntimePermission(session.child, event.reportedOptions, true)
|
||||
context.publishStatus?.(event.sessionId)
|
||||
await noteStructuredAgentSessionReportedOptions(
|
||||
context,
|
||||
event.sessionId,
|
||||
@@ -221,7 +226,14 @@ export async function settleStructuredAgentSessionOptionsReported(
|
||||
event: StructuredAgentSessionOptionsReportedEvent
|
||||
): Promise<void> {
|
||||
const child = context.sessions.get(event.sessionId)?.child
|
||||
if (child?.phase === 'starting') {
|
||||
if (
|
||||
!child ||
|
||||
!sameProviderChild(child, { generation: event.acquisitionGeneration, fence: event.fence })
|
||||
) {
|
||||
return
|
||||
}
|
||||
noteStructuredAgentSessionRuntimePermission(child, event.reportedOptions)
|
||||
if (child.phase === 'starting') {
|
||||
const readiness = context.runtimeState.startupAttempts.readiness(event.sessionId, {
|
||||
generation: event.acquisitionGeneration,
|
||||
fence: event.fence
|
||||
@@ -230,6 +242,10 @@ export async function settleStructuredAgentSessionOptionsReported(
|
||||
return
|
||||
}
|
||||
}
|
||||
if (context.sessions.get(event.sessionId)?.child !== child) {
|
||||
return
|
||||
}
|
||||
context.publishStatus?.(event.sessionId)
|
||||
await noteStructuredAgentSessionReportedOptions(
|
||||
context,
|
||||
event.sessionId,
|
||||
|
||||
@@ -27,7 +27,7 @@ export type StructuredAgentSessionHandedOptions = {
|
||||
/** Per-child reports and a handed-over turn awaiting its first model evidence. */
|
||||
const childRuns = new WeakMap<
|
||||
StructuredAgentSessionProviderChild,
|
||||
{ reported?: RanOptions; owed?: StructuredAgentSessionHandedOptions }
|
||||
{ reported?: RanOptions; owed?: StructuredAgentSessionHandedOptions; permission?: string | null }
|
||||
>()
|
||||
|
||||
function currentChild(context: RanOptionsContext, sessionId: string, fence: number) {
|
||||
@@ -41,6 +41,28 @@ export function structuredAgentSessionChildRan(
|
||||
return childRuns.get(child)?.reported
|
||||
}
|
||||
|
||||
/** The current child's descriptive report; never a saved or applied permission. */
|
||||
export function structuredAgentSessionRuntimePermission(
|
||||
child: StructuredAgentSessionProviderChild | undefined
|
||||
): { fence: number; mode: string | null } | null {
|
||||
const mode = child && childRuns.get(child)?.permission
|
||||
return child?.phase === 'ready' && mode !== undefined ? { fence: child.fence, mode } : null
|
||||
}
|
||||
|
||||
export function noteStructuredAgentSessionRuntimePermission(
|
||||
child: StructuredAgentSessionProviderChild,
|
||||
reported: AgentSessionOptionsResult['current'],
|
||||
initial = false
|
||||
): void {
|
||||
const previous = childRuns.get(child)
|
||||
if (
|
||||
reported.reportedPermissionMode !== undefined &&
|
||||
(!initial || previous?.permission === undefined)
|
||||
) {
|
||||
childRuns.set(child, { ...previous, permission: reported.reportedPermissionMode })
|
||||
}
|
||||
}
|
||||
|
||||
function reportedRan(reported: AgentSessionOptionsResult['current']): RanOptions | null {
|
||||
const { model, effort, serviceTier, fastMode, fastModeState } = reported
|
||||
const filled = reported.defaulted ?? []
|
||||
@@ -69,7 +91,7 @@ export async function noteStructuredAgentSessionReportedOptions(
|
||||
return
|
||||
}
|
||||
const runs = childRuns.get(child)
|
||||
childRuns.set(child, { reported: ran })
|
||||
childRuns.set(child, { ...runs, reported: ran })
|
||||
if (runs?.owed && ran.model) {
|
||||
const owed = runs.owed
|
||||
await recordStructuredAgentSessionRanOptions(context, sessionId, fence, {
|
||||
@@ -132,7 +154,7 @@ export async function recordStructuredAgentSessionRanOptions(
|
||||
const model = executed
|
||||
? (executed.model ?? message?.model ?? newerModel)
|
||||
: (message?.model ?? reported?.model)
|
||||
childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: handed }) })
|
||||
childRuns.set(child, { ...childRuns.get(child), owed: model ? undefined : handed })
|
||||
if (!model) {
|
||||
return
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,7 +8,7 @@ import type {
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
|
||||
|
||||
/** Absolute registration-time ceiling, independent of shorter protocol-step bounds. */
|
||||
export const STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS = 10 * 60_000
|
||||
@@ -29,7 +29,8 @@ export type StructuredAgentSessionStartupAttempt = {
|
||||
readonly launch: Pick<AgentSessionRecord, 'location' | 'accountHome' | 'launchDirectory'>
|
||||
/** Saved launch intent, never catalog guesses. */
|
||||
readonly options?: Readonly<Record<string, string>>
|
||||
readonly permissionMode?: AgentChatPermissionMode
|
||||
readonly permissionMode?: AgentChatPermissionChoice
|
||||
/** Provider events may begin before spawn returns. */
|
||||
readonly events?: StructuredAgentSessionEventSink
|
||||
/** Attempt-scoped background evidence; adapters use registration reporting when absent. */
|
||||
readonly childWork?: (evidence: AgentChildWorkEvidence[]) => void
|
||||
|
||||
+56
-1
@@ -93,7 +93,9 @@ async function startHost(): Promise<void> {
|
||||
adapter: {
|
||||
open: ({ acquisitionGeneration }) =>
|
||||
new Promise((resolve) => {
|
||||
openGates.set(acquisitionGeneration!, (model) => resolve({ reportedOptions: { model } }))
|
||||
openGates.set(acquisitionGeneration!, (model) =>
|
||||
resolve({ reportedOptions: { model, reportedPermissionMode: 'ask' } })
|
||||
)
|
||||
}),
|
||||
acquire,
|
||||
dispatch,
|
||||
@@ -270,6 +272,59 @@ describe('a send while the agent starts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps current-generation reports across readiness (replaced=%s)',
|
||||
async (replaced) => {
|
||||
await store.replaceSessionOptions({
|
||||
sessionId: SESSION,
|
||||
fence: store.getRecord(SESSION)!.lease.runtimeFence,
|
||||
permissionMode: 'own',
|
||||
now: NOW
|
||||
})
|
||||
await restartWith(publishFirst)
|
||||
const id = await heldBehindStart('hello')
|
||||
const fence = store.getRecord(SESSION)!.lease.runtimeFence
|
||||
const acquisitionGeneration = `generation-${acquire.mock.calls.length}`
|
||||
const report = (
|
||||
reportedOptions: { reportedPermissionMode?: string | null },
|
||||
generation = acquisitionGeneration,
|
||||
reportFence = fence
|
||||
) =>
|
||||
host.handleAdapterEvent({
|
||||
type: 'options-reported',
|
||||
sessionId: SESSION,
|
||||
fence: reportFence,
|
||||
acquisitionGeneration: generation,
|
||||
reportedOptions
|
||||
})
|
||||
const early = report({ reportedPermissionMode: 'accept-edits' })
|
||||
expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull()
|
||||
if (replaced) {
|
||||
await host.close(SESSION, 'evict')
|
||||
await early
|
||||
await proveOpen()
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull()
|
||||
return
|
||||
}
|
||||
await proveOpen()
|
||||
await early
|
||||
await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted'))
|
||||
const current = () => host.readStatusSummary(SESSION)?.runtimePermission
|
||||
expect(current()).toEqual({ fence, mode: 'accept-edits' })
|
||||
await report({ reportedPermissionMode: 'full-access' }, 'foreign')
|
||||
await report({ reportedPermissionMode: 'full-access' }, acquisitionGeneration, fence + 1)
|
||||
await report({})
|
||||
expect(current()).toEqual({ fence, mode: 'accept-edits' })
|
||||
await report({ reportedPermissionMode: null })
|
||||
expect(current()).toEqual({ fence, mode: null })
|
||||
expect(store.getRecord(SESSION)?.permissionMode).toBe('own')
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(current()).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
describe('a start that ends before it proves itself', () => {
|
||||
it.each([false, true])(
|
||||
'settles only its own message after startup failure (acquisition refusal: %s)',
|
||||
|
||||
@@ -8,7 +8,7 @@ export type { StructuredAgentSessionStartReadiness } from './structured-agent-se
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types'
|
||||
import { sameProviderChild } from './structured-agent-session-provider-child'
|
||||
import {
|
||||
@@ -21,7 +21,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: {
|
||||
record: AgentSessionRecord
|
||||
identity: AgentSessionJournalIdentity
|
||||
spawnToken: string
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
permissionMode?: AgentChatPermissionChoice
|
||||
events?: StructuredAgentSessionEventSink
|
||||
signal?: AbortSignal
|
||||
startupOptions?: Readonly<Record<string, string>>
|
||||
|
||||
@@ -69,3 +69,28 @@ export function structuredAgentSessionNamingSnapshot(
|
||||
namingActiveSessionIds: deps.namingActiveSessionIds?.() ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/** Activity observers remain separate from descriptive provider reports. */
|
||||
export function notifyStructuredStatusObservers(
|
||||
deps: StructuredAgentSessionStatusFeedDeps,
|
||||
summary: AgentSessionStatusSummary,
|
||||
previous: AgentSessionStatusSummary | undefined,
|
||||
options: StructuredAgentSessionStatusObserverOptions & { reportOnly: boolean }
|
||||
): void {
|
||||
if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') {
|
||||
deps.onAgentStarted?.(summary.sessionId)
|
||||
}
|
||||
const { reportOnly, ...observation } = options
|
||||
if (reportOnly) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
deps.onStatusChanged?.(summary, observation)
|
||||
} catch (error) {
|
||||
deps.logger.warn('status observer failed', {
|
||||
scope: 'status-observer',
|
||||
sessionId: summary.sessionId,
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ describe('StructuredAgentSessionStatusFeed', () => {
|
||||
agent: 'codex',
|
||||
status: null,
|
||||
latestPrompt: '',
|
||||
runtimePermission: null,
|
||||
updatedAt: expect.any(Number)
|
||||
}
|
||||
]
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
structuredAgentSessionNamingSnapshot,
|
||||
notifyStructuredStatusObservers,
|
||||
type StatusFeedSession,
|
||||
type StatusPublication,
|
||||
type StructuredAgentSessionStatusSubscriber,
|
||||
@@ -25,7 +26,10 @@ import {
|
||||
StructuredAgentSessionJournalProjections,
|
||||
type StructuredAgentSessionJournalProjection
|
||||
} from './structured-agent-session-status-journal-projection'
|
||||
import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality'
|
||||
import {
|
||||
permissionReportOnly,
|
||||
structuredStatusSummariesEqual
|
||||
} from './structured-agent-session-status-summary-equality'
|
||||
import type { StructuredAgentSessionChildWorkProducer } from './structured-agent-session-subagent-rows'
|
||||
import {
|
||||
StructuredAgentSessionStatusOwnership,
|
||||
@@ -127,6 +131,7 @@ export class StructuredAgentSessionStatusFeed {
|
||||
restartResume: _restartResume,
|
||||
...retained
|
||||
} = previous
|
||||
retained.runtimePermission = null
|
||||
this.published.set(sessionId, {
|
||||
summary: retained,
|
||||
firstInputSubmissionKey: publication?.firstInputSubmissionKey ?? null
|
||||
@@ -192,6 +197,7 @@ export class StructuredAgentSessionStatusFeed {
|
||||
const previous = publication?.summary
|
||||
const summaryChanged = !previous || !structuredStatusSummariesEqual(previous, summary)
|
||||
const inputChanged = publication?.firstInputSubmissionKey !== projection.firstInputSubmissionKey
|
||||
const reportOnly = !inputChanged && permissionReportOnly(previous, summary)
|
||||
if (!summaryChanged && !inputChanged) {
|
||||
if (!this.ownership.matchesLocation(sessionId, session.params.location)) {
|
||||
this.sink(summary, session.params.location)
|
||||
@@ -203,20 +209,16 @@ export class StructuredAgentSessionStatusFeed {
|
||||
firstInputSubmissionKey: projection.firstInputSubmissionKey
|
||||
})
|
||||
if (summaryChanged) {
|
||||
this.sink(summary, session.params.location)
|
||||
if (!reportOnly || !this.ownership.matchesLocation(sessionId, session.params.location)) {
|
||||
this.sink(summary, session.params.location)
|
||||
}
|
||||
this.broadcast({ type: 'status', session: summary })
|
||||
}
|
||||
if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') {
|
||||
this.deps.onAgentStarted?.(sessionId)
|
||||
}
|
||||
try {
|
||||
this.deps.onStatusChanged?.(summary, {
|
||||
replay: options?.replay === true,
|
||||
firstInputSubmissionKey: projection.firstInputSubmissionKey
|
||||
})
|
||||
} catch (error) {
|
||||
this.logFailure('status-observer', 'status observer failed', sessionId, error)
|
||||
}
|
||||
notifyStructuredStatusObservers(this.deps, summary, previous, {
|
||||
reportOnly,
|
||||
replay: options?.replay === true,
|
||||
firstInputSubmissionKey: projection.firstInputSubmissionKey
|
||||
})
|
||||
}
|
||||
|
||||
/** Retain settled children until the next accepted send to reject late revival and allow final outcomes.
|
||||
@@ -226,10 +228,9 @@ export class StructuredAgentSessionStatusFeed {
|
||||
session: StatusFeedSession,
|
||||
acceptedSendKey: string
|
||||
): void {
|
||||
const seen = this.acceptedSends.has(sessionId)
|
||||
const previous = this.acceptedSends.get(sessionId)
|
||||
this.acceptedSends.set(sessionId, acceptedSendKey)
|
||||
if (!seen || acceptedSendKey === previous) {
|
||||
if (previous === undefined || acceptedSendKey === previous) {
|
||||
return
|
||||
}
|
||||
this.admitChildWork(sessionId, session, [{ type: 'turn-started', observedAt: this.deps.now() }])
|
||||
|
||||
+16
@@ -27,6 +27,9 @@ export function structuredStatusSummariesEqual(
|
||||
a.latestPrompt === b.latestPrompt &&
|
||||
a.model === b.model &&
|
||||
a.permissionMode === b.permissionMode &&
|
||||
a.runtimePermission?.fence === b.runtimePermission?.fence &&
|
||||
a.runtimePermission?.mode === b.runtimePermission?.mode &&
|
||||
(a.runtimePermission === undefined) === (b.runtimePermission === undefined) &&
|
||||
a.toolName === b.toolName &&
|
||||
a.toolInput === b.toolInput &&
|
||||
a.lastAssistantMessage === b.lastAssistantMessage &&
|
||||
@@ -55,3 +58,16 @@ function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function permissionReportOnly(
|
||||
previous: AgentSessionStatusSummary | undefined,
|
||||
summary: AgentSessionStatusSummary
|
||||
): boolean {
|
||||
return Boolean(
|
||||
previous &&
|
||||
structuredStatusSummariesEqual(
|
||||
{ ...previous, runtimePermission: summary.runtimePermission },
|
||||
summary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { structuredAgentSessionRuntimePermission } from './structured-agent-session-ran-options'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization'
|
||||
@@ -29,7 +30,7 @@ export function structuredAgentSessionStatusSummary({
|
||||
sessionId: string
|
||||
session: {
|
||||
params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] }
|
||||
child?: Pick<StructuredAgentSessionProviderChild, 'phase'> | null
|
||||
child?: StructuredAgentSessionProviderChild | null
|
||||
restartResume?: AgentSessionStatusSummary['restartResume']
|
||||
}
|
||||
journal: AgentSessionJournal
|
||||
@@ -59,6 +60,8 @@ export function structuredAgentSessionStatusSummary({
|
||||
: {}),
|
||||
...(session.restartResume ? { restartResume: { phase: session.restartResume.phase } } : {}),
|
||||
...projected,
|
||||
runtimePermission: structuredAgentSessionRuntimePermission(session.child ?? undefined),
|
||||
// Only a working session is still being stopped; any other status already ended that work.
|
||||
...(stopping && projected.status === 'working' ? { stopping: true as const } : {}),
|
||||
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
|
||||
? { rewindBlockedReason: 'outcome-unknown' as const }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import {
|
||||
isAgentChatPermissionMode,
|
||||
publishedLegacyChatPermissionMode
|
||||
} from '../../../shared/agent-chat-permission-mode'
|
||||
import { applyAllAgentsPermission } from '../../../shared/tui-agent-permission-choice'
|
||||
|
||||
/** Old dropdown writes use the replacement control's reset; an explicit typed write wins. */
|
||||
export function legacyChatPermissionWrite(
|
||||
settings: GlobalSettings,
|
||||
updates: Partial<GlobalSettings>
|
||||
): Partial<GlobalSettings> {
|
||||
const choice =
|
||||
'nativeChatPermissionMode' in updates && !('agentPermissionDefault' in updates)
|
||||
? applyAllAgentsPermission(
|
||||
settings,
|
||||
isAgentChatPermissionMode(updates.nativeChatPermissionMode)
|
||||
? updates.nativeChatPermissionMode
|
||||
: 'ask'
|
||||
)
|
||||
: {}
|
||||
return {
|
||||
...choice,
|
||||
nativeChatPermissionMode: publishedLegacyChatPermissionMode({
|
||||
...settings,
|
||||
...updates,
|
||||
...choice
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -122,25 +122,3 @@ describe('updateSettings terminalMinimumContrastRatio', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSettings scoped chat defaults', () => {
|
||||
it('clears agent overrides for a shared chat permission choice from any writer', () => {
|
||||
const operations = settingsOperations({
|
||||
nativeChatPermissionMode: 'full-access',
|
||||
agentNativeChatPermissionModes: { codex: 'auto' }
|
||||
})
|
||||
expect(
|
||||
updateSettings(operations, { nativeChatPermissionMode: 'ask' }).agentNativeChatPermissionModes
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('retains explicit overrides from a bulk mutation or durable rollback', () => {
|
||||
const operations = settingsOperations({ nativeChatPermissionMode: 'ask' })
|
||||
expect(
|
||||
updateSettings(operations, {
|
||||
nativeChatPermissionMode: 'full-access',
|
||||
agentNativeChatPermissionModes: { codex: 'auto' }
|
||||
}).agentNativeChatPermissionModes
|
||||
).toEqual({ codex: 'auto' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeAgentNativeChatPermissionModes } from '../../../shared/agent-chat-permission-mode'
|
||||
import { legacyChatPermissionWrite } from './legacy-chat-permission-write'
|
||||
import { normalizeNativeChatAppearanceSettings } from '../../../shared/native-chat-appearance-settings'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import { retireAtRestCommandSettings } from '../../native-chat/agent-session-wire/at-rest-command-context'
|
||||
@@ -81,11 +81,6 @@ export function updateSettings(
|
||||
nestedWorkerMaxDepth: updates.nestedWorkerMaxDepth
|
||||
})
|
||||
}
|
||||
if ('nativeChatPermissionMode' in updates || 'agentNativeChatPermissionModes' in updates) {
|
||||
sanitizedUpdates.agentNativeChatPermissionModes = normalizeAgentNativeChatPermissionModes(
|
||||
updates.agentNativeChatPermissionModes
|
||||
)
|
||||
}
|
||||
if ('nativeChatAppearance' in updates) {
|
||||
sanitizedUpdates.nativeChatAppearance = normalizeNativeChatAppearanceSettings(
|
||||
updates.nativeChatAppearance
|
||||
@@ -108,6 +103,12 @@ export function updateSettings(
|
||||
}
|
||||
}
|
||||
sanitizeAgentLaunchProfileUpdates(sanitizedUpdates, updates)
|
||||
if ('nativeChatPermissionMode' in updates || 'agentPermissionDefault' in updates) {
|
||||
Object.assign(
|
||||
sanitizedUpdates,
|
||||
legacyChatPermissionWrite(operations.state.settings, sanitizedUpdates)
|
||||
)
|
||||
}
|
||||
if ('terminalQuickCommands' in updates) {
|
||||
const terminalQuickCommands = normalizeTerminalQuickCommands(updates.terminalQuickCommands)
|
||||
assertTerminalQuickCommandsStorable(terminalQuickCommands)
|
||||
|
||||
@@ -57,6 +57,7 @@ export function readLegacyTerminalScrollbackSettings(
|
||||
}
|
||||
|
||||
type RetiredGlobalSettings = {
|
||||
agentNativeChatPermissionModes?: unknown
|
||||
setupScriptLaunchMode?: unknown
|
||||
terminalScrollbackBytes?: unknown
|
||||
enableGitHubAttribution?: unknown
|
||||
@@ -73,6 +74,7 @@ export function stripRetiredGlobalSettings(
|
||||
settings: Partial<GlobalSettings> | undefined
|
||||
): Partial<GlobalSettings> {
|
||||
const {
|
||||
agentNativeChatPermissionModes: _retiredChatPermissionCopies,
|
||||
setupScriptLaunchMode: _legacySetupScriptLaunchMode,
|
||||
terminalScrollbackBytes: _legacyScrollbackBytes,
|
||||
enableGitHubAttribution: _legacyGitHubAttribution,
|
||||
@@ -83,6 +85,7 @@ export function stripRetiredGlobalSettings(
|
||||
openAgentTabsInChatByDefault: _legacyChatDefaultView,
|
||||
...rest
|
||||
} = (settings ?? {}) as Partial<GlobalSettings> & RetiredGlobalSettings
|
||||
void _retiredChatPermissionCopies
|
||||
void _legacySetupScriptLaunchMode
|
||||
void _legacyScrollbackBytes
|
||||
void _legacyGitHubAttribution
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { newChatPermissionModeForSettings } from '../../../shared/agent-chat-permission-mode'
|
||||
import { homedir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultPersistedState } from '../../../shared/constants'
|
||||
@@ -6,7 +7,6 @@ import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-setting
|
||||
import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { applyAgentPermissionMode } from '../../../shared/tui-agent-permissions'
|
||||
import { resolveAgentPermission } from '../../../shared/tui-agent-permission-choice'
|
||||
import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults'
|
||||
|
||||
@@ -164,7 +164,6 @@ describe('the new-chat permission setting', () => {
|
||||
saved: Partial<GlobalSettings>
|
||||
raw: string[]
|
||||
choices: string[]
|
||||
chat: string
|
||||
terminal: string[]
|
||||
}>([
|
||||
{
|
||||
@@ -178,7 +177,6 @@ describe('the new-chat permission setting', () => {
|
||||
},
|
||||
raw: ['--model sonnet', '--config tui.animations=false'],
|
||||
choices: ['full-access', 'auto'],
|
||||
chat: 'ask',
|
||||
terminal: [
|
||||
'--dangerously-skip-permissions --model sonnet',
|
||||
'--approve-for-me --config tui.animations=false'
|
||||
@@ -193,7 +191,6 @@ describe('the new-chat permission setting', () => {
|
||||
},
|
||||
raw: ['', ''],
|
||||
choices: ['own', 'own'],
|
||||
chat: 'auto',
|
||||
terminal: ['', '']
|
||||
},
|
||||
{
|
||||
@@ -205,7 +202,6 @@ describe('the new-chat permission setting', () => {
|
||||
},
|
||||
raw: ['--permission-mode plan --model sonnet', ''],
|
||||
choices: ['own', 'own'],
|
||||
chat: 'accept-edits',
|
||||
terminal: ['--permission-mode plan --model sonnet', '']
|
||||
},
|
||||
{
|
||||
@@ -213,13 +209,15 @@ describe('the new-chat permission setting', () => {
|
||||
saved: { agentYoloDefaultsMigrated: false, agentDefaultArgs: {} },
|
||||
raw: ['', ''],
|
||||
choices: ['full-access', 'full-access'],
|
||||
chat: 'full-access',
|
||||
terminal: ['--dangerously-skip-permissions', '--dangerously-bypass-approvals-and-sandbox']
|
||||
}
|
||||
])(
|
||||
'loads and reloads $name through the real profile pipeline',
|
||||
({ saved, raw, choices, chat, terminal }) => {
|
||||
({ saved, raw, choices, terminal }) => {
|
||||
const defaults = getDefaultPersistedState(homedir())
|
||||
const retiredChatSettings = {
|
||||
agentNativeChatPermissionModes: { claude: 'ask', codex: 'auto' }
|
||||
}
|
||||
const parsed: PersistedState = {
|
||||
...defaults,
|
||||
settings: {
|
||||
@@ -227,7 +225,7 @@ describe('the new-chat permission setting', () => {
|
||||
agentPermissionDefault: undefined,
|
||||
agentPermissionOverrides: undefined,
|
||||
nativeChatPermissionMode: undefined,
|
||||
agentNativeChatPermissionModes: { claude: 'ask', codex: 'auto' },
|
||||
...retiredChatSettings,
|
||||
agentDefaultEnv: { claude: { QA_LOADER_NOTE: 'preserved' } },
|
||||
...saved
|
||||
}
|
||||
@@ -242,8 +240,12 @@ describe('the new-chat permission setting', () => {
|
||||
expect(needsSave).toHaveBeenCalled()
|
||||
expect(loaded.agentYoloDefaultsMigrated).toBe(true)
|
||||
expect(loaded.agentPermissionDefault).toBeDefined()
|
||||
expect(loaded.nativeChatPermissionMode).toBe(chat)
|
||||
expect(loaded.agentNativeChatPermissionModes).toEqual({ claude: 'ask', codex: 'auto' })
|
||||
expect(
|
||||
(['claude', 'codex'] as const).map((agent) =>
|
||||
newChatPermissionModeForSettings(agent, loaded)
|
||||
)
|
||||
).toEqual(choices)
|
||||
expect(loaded).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
expect(loaded.agentDefaultEnv?.claude).toEqual({ QA_LOADER_NOTE: 'preserved' })
|
||||
expect(
|
||||
(['claude', 'codex'] as const).map((agent) => loaded.agentDefaultArgs?.[agent])
|
||||
@@ -264,38 +266,42 @@ describe('the new-chat permission setting', () => {
|
||||
).toEqual(loaded)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['manual', 'ask'],
|
||||
['yolo', 'full-access']
|
||||
] as const)('seeds a profile without it from %s Agent Permissions', (mode, expected) => {
|
||||
const normalized = normalizeLegacyProfile({
|
||||
nativeChatPermissionMode: undefined,
|
||||
...applyAgentPermissionMode({ mode })
|
||||
})
|
||||
expect(normalized.nativeChatPermissionMode).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps a saved value when Agent Permissions change afterwards', () => {
|
||||
const normalized = normalizeLegacyProfile({
|
||||
nativeChatPermissionMode: 'ask',
|
||||
...applyAgentPermissionMode({ mode: 'yolo' })
|
||||
})
|
||||
expect(normalized.nativeChatPermissionMode).toBe('ask')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped new-chat permission defaults', () => {
|
||||
it('preserves an agent override across load while rejecting unsupported stored modes', () => {
|
||||
it('retires old chat copies while terminal intent and existing chat choices survive', () => {
|
||||
const normalized = normalizeLegacyProfile({
|
||||
nativeChatPermissionMode: 'full-access',
|
||||
agentNativeChatPermissionModes: { codex: 'auto', claude: 'ask', omp: 'auto', unknown: 'auto' }
|
||||
})
|
||||
expect(normalized.agentNativeChatPermissionModes).toEqual({ codex: 'auto', claude: 'ask' })
|
||||
expect(normalized).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
expect(normalized.nativeChatPermissionMode).toBe('full-access')
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates a shipped split chat/terminal profile using terminal choices while retaining other saved data', () => {
|
||||
const normalized = normalizeLegacyProfile({
|
||||
agentPermissionDefault: undefined,
|
||||
agentPermissionOverrides: undefined,
|
||||
agentPermissionMigrated: false,
|
||||
agentDefaultArgs: { claude: '--model opus --permission-mode acceptEdits', codex: '' },
|
||||
nativeChatPermissionMode: 'ask',
|
||||
agentNativeChatPermissionModes: { claude: 'full-access', codex: 'auto' },
|
||||
nativeChatSessionOptions: {
|
||||
claude: { model: 'opus', valuesByModel: { opus: { effort: 'high' } } }
|
||||
}
|
||||
})
|
||||
expect(normalized).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
expect(newChatPermissionModeForSettings('claude', normalized)).toBe('accept-edits')
|
||||
expect(newChatPermissionModeForSettings('codex', normalized)).toBe('own')
|
||||
expect(normalized.nativeChatPermissionMode).toBe('ask')
|
||||
expect(normalized.agentDefaultArgs?.claude).toBe('--model opus')
|
||||
expect(normalized.nativeChatSessionOptions?.claude).toEqual({
|
||||
model: 'opus',
|
||||
valuesByModel: { opus: { effort: 'high' } }
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal default shell', () => {
|
||||
it('trims a hand-edited value and treats whitespace-only as unset', () => {
|
||||
expect(
|
||||
|
||||
@@ -9,10 +9,7 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t
|
||||
import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell'
|
||||
import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai'
|
||||
import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat-shell-environment'
|
||||
import {
|
||||
loadedChatPermissionSetting,
|
||||
normalizeAgentNativeChatPermissionModes
|
||||
} from '../../../shared/agent-chat-permission-mode'
|
||||
import { publishedLegacyChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
|
||||
import {
|
||||
savedChatUiWithTerminalDefaultView,
|
||||
stripRetiredGlobalSettings
|
||||
@@ -134,17 +131,12 @@ export function normalizeLoadedGlobalSettings(
|
||||
nativeChatShellEnvironmentVariables: normalizeNativeChatShellEnvironmentVariables(
|
||||
parsed.settings?.nativeChatShellEnvironmentVariables
|
||||
),
|
||||
agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes(
|
||||
parsed.settings?.agentNativeChatPermissionModes
|
||||
),
|
||||
nativeChatPermissionMode: loadedChatPermissionSetting(
|
||||
parsed.settings?.nativeChatPermissionMode,
|
||||
{
|
||||
...migratedAgentYoloDefaults,
|
||||
terminalWindowsShell: parsed.settings?.terminalWindowsShell
|
||||
},
|
||||
process.platform
|
||||
),
|
||||
// Old clients identify hosts with per-chat permissions through this valid value.
|
||||
nativeChatPermissionMode: publishedLegacyChatPermissionMode({
|
||||
...parsed.settings,
|
||||
...migratedAgentYoloDefaults,
|
||||
...migratedAgentPermission
|
||||
}),
|
||||
defaultTaskSource: taskProviderSettings.defaultTaskSource,
|
||||
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
|
||||
visibleTaskProvidersDefaultedForJira: true,
|
||||
|
||||
@@ -43,10 +43,6 @@ export function agentLaunchSettingsRollbackUpdates(
|
||||
Object.is(current.agentPermissionOverrides, applied.agentPermissionOverrides)
|
||||
? { agentPermissionOverrides: before.agentPermissionOverrides ?? {} }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, 'agentNativeChatPermissionModes') &&
|
||||
Object.is(current.agentNativeChatPermissionModes, applied.agentNativeChatPermissionModes)
|
||||
? { agentNativeChatPermissionModes: before.agentNativeChatPermissionModes ?? {} }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, 'nativeChatPermissionMode') &&
|
||||
Object.is(current.nativeChatPermissionMode, applied.nativeChatPermissionMode)
|
||||
? { nativeChatPermissionMode: before.nativeChatPermissionMode ?? 'ask' }
|
||||
|
||||
@@ -31,6 +31,43 @@ vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
|
||||
}))
|
||||
|
||||
describe('durable execution-host launch settings', () => {
|
||||
it('maps an old dropdown through the all-agents reset, persists it and publishes typed precedence', async () => {
|
||||
const { store, readState } = await fixture()
|
||||
const controller = new RuntimeClientSettingsController(store)
|
||||
await controller.mutateAgentLaunch({
|
||||
type: 'permission',
|
||||
agent: 'claude',
|
||||
choice: 'full-access'
|
||||
})
|
||||
await controller.update({ nativeChatPermissionMode: 'ask' })
|
||||
await store.flushPendingOrThrowAsync()
|
||||
expect(readState().settings).toMatchObject({
|
||||
agentPermissionDefault: 'ask',
|
||||
agentPermissionOverrides: {}
|
||||
})
|
||||
expect(controller.get().nativeChatPermissionMode).toBe('ask')
|
||||
expect(resolveTuiAgentLaunchArgs('claude', store.getSettings())).toBe(
|
||||
'--permission-mode default'
|
||||
)
|
||||
expect(newChatPermissionModeForSettings('claude', store.getSettings())).toBe('ask')
|
||||
store.updateSettings({ nativeChatPermissionMode: 'full-access', agentPermissionDefault: 'own' })
|
||||
await store.flushPendingOrThrowAsync()
|
||||
const saved = readState()
|
||||
expect(saved.settings.agentPermissionDefault).toBe('own')
|
||||
const reloaded = new Store({
|
||||
serializedState: JSON.stringify(saved),
|
||||
dataFile: join(saved.settings.workspaceDir, 'legacy-permission-reload.json')
|
||||
})
|
||||
try {
|
||||
expect(new RuntimeClientSettingsController(reloaded).get().nativeChatPermissionMode).toBe(
|
||||
'ask'
|
||||
)
|
||||
expect(newChatPermissionModeForSettings('claude', reloaded.getSettings())).toBe('own')
|
||||
} finally {
|
||||
await reloaded.freezeWritesAsync()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists host carrier edits and reloads the same terminal and new-chat intent', async () => {
|
||||
const { store, readState } = await fixture()
|
||||
const controller = new RuntimeClientSettingsController(store)
|
||||
@@ -344,11 +381,8 @@ describe('durable execution-host launch settings', () => {
|
||||
expect(store.getSettings().nativeChatPermissionMode).toBe(before)
|
||||
expect(readState().settings.nativeChatPermissionMode).toBe(before)
|
||||
await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'auto' })
|
||||
expect(readState().settings.nativeChatPermissionMode).toBe(before)
|
||||
expect(readState().settings.agentNativeChatPermissionModes).toEqual({
|
||||
claude: 'auto',
|
||||
codex: 'auto'
|
||||
})
|
||||
expect(readState().settings.nativeChatPermissionMode).toBe('auto')
|
||||
expect(readState().settings).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
})
|
||||
|
||||
it('rolls back a scoped chat default after a failed write and persists a retry', async () => {
|
||||
@@ -370,14 +404,14 @@ describe('durable execution-host launch settings', () => {
|
||||
await store.flushPendingOrThrowAsync().catch(() => {})
|
||||
}
|
||||
expect(store.getSettings().agentDefaultArgs?.codex).toBe(beforeArgs)
|
||||
expect(store.getSettings().agentNativeChatPermissionModes).toEqual({})
|
||||
expect(store.getSettings()).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
await controller.mutateAgentLaunch({
|
||||
type: 'permissions',
|
||||
syncNativeChat: true,
|
||||
agent: 'codex',
|
||||
mode: 'auto'
|
||||
})
|
||||
expect(readState().settings.agentNativeChatPermissionModes).toEqual({ codex: 'auto' })
|
||||
expect(readState().settings).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
expect(readState().settings.nativeChatPermissionMode).toBe(beforeNative)
|
||||
expect(readState().settings.agentPermissionOverrides).toMatchObject({ codex: 'auto' })
|
||||
})
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('execution-host agent launch settings', () => {
|
||||
mode: 'manual'
|
||||
})
|
||||
expect(resolveTuiAgentLaunchArgs('claude', host)).toBe('')
|
||||
expect(host.nativeChatPermissionMode).toBe('ask')
|
||||
expect(host.nativeChatPermissionMode).toBe('full-access')
|
||||
expect(host.agentDefaultEnv?.claude).toEqual({ API_KEY: 'host-secret', KEEP: 'host-only' })
|
||||
expect(JSON.stringify(controller.getAgentLaunch())).not.toContain('host-only')
|
||||
})
|
||||
@@ -137,7 +137,7 @@ describe('execution-host agent launch settings', () => {
|
||||
codex: '--approve-for-me'
|
||||
})
|
||||
expect(host.nativeChatPermissionMode).toBe(beforeNative)
|
||||
expect(host.agentNativeChatPermissionModes).toEqual({ claude: 'auto', codex: 'auto' })
|
||||
expect(host).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
})
|
||||
|
||||
it('publishes scoped host defaults and resets them when a paired client chooses a shared chat default', async () => {
|
||||
@@ -149,10 +149,10 @@ describe('execution-host agent launch settings', () => {
|
||||
agent: 'codex',
|
||||
mode: 'auto'
|
||||
})
|
||||
expect(controller.get().agentNativeChatPermissionModes).toEqual({ codex: 'auto' })
|
||||
expect(controller.get()).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
expect(host.agentDefaultArgs.claude).toBe('')
|
||||
await controller.update({ nativeChatPermissionMode: 'ask' })
|
||||
expect(controller.get().agentNativeChatPermissionModes).toEqual({})
|
||||
expect(controller.get()).not.toHaveProperty('agentNativeChatPermissionModes')
|
||||
expect(host.nativeChatPermissionMode).toBe('ask')
|
||||
})
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ export type RuntimeClientSettings = Pick<
|
||||
| 'agentSkillSharingEnabled'
|
||||
| 'machineName'
|
||||
| 'nativeChatPermissionMode'
|
||||
| 'agentNativeChatPermissionModes'
|
||||
> & {
|
||||
/** Older clients still read these; neither is persisted or accepted as an update. */
|
||||
openAgentTabsInChatByDefault?: boolean
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publishedLegacyChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
|
||||
import { isAgentSkillSharingEnabled } from '../../shared/agent-skill-sharing-gate'
|
||||
@@ -88,14 +89,9 @@ export class RuntimeClientSettingsController {
|
||||
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
|
||||
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings),
|
||||
machineName: settings.machineName ?? '',
|
||||
// Paired web Chat settings edit this host's new-chat default.
|
||||
...(settings.nativeChatPermissionMode
|
||||
? { nativeChatPermissionMode: settings.nativeChatPermissionMode }
|
||||
: {}),
|
||||
...(settings.agentNativeChatPermissionModes
|
||||
? { agentNativeChatPermissionModes: settings.agentNativeChatPermissionModes }
|
||||
: {}),
|
||||
// Project per-action agent recipes so paired AI buttons honor host choices; older hosts omit them.
|
||||
nativeChatPermissionMode: publishedLegacyChatPermissionMode(settings),
|
||||
// Why projected: a paired client's AI buttons start these actions' agents, and must honour
|
||||
// the agent saved for each one as the desktop does. Absent on older hosts.
|
||||
sourceControlAi: projectSourceControlLaunchRecipes(settings),
|
||||
hostSettingOverrides: Object.fromEntries(
|
||||
[
|
||||
@@ -126,9 +122,6 @@ export class RuntimeClientSettingsController {
|
||||
process.platform
|
||||
)
|
||||
: {}),
|
||||
...(updates.nativeChatPermissionMode !== undefined
|
||||
? { agentNativeChatPermissionModes: {} }
|
||||
: {}),
|
||||
...(updates.defaultTuiAgent !== undefined ? { newWorkspaceSetupOnly: false } : {})
|
||||
},
|
||||
{ notifyListeners: true }
|
||||
|
||||
@@ -175,7 +175,6 @@ export type RuntimeStore = {
|
||||
agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled']
|
||||
nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions']
|
||||
nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode']
|
||||
agentNativeChatPermissionModes?: GlobalSettings['agentNativeChatPermissionModes']
|
||||
nativeChatInlineVisuals?: GlobalSettings['nativeChatInlineVisuals']
|
||||
nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment']
|
||||
nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Build agent launch resolvers with production composition over test doubles so tests exercise the shared path.
|
||||
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
|
||||
import type { AcpLaunchSpec } from '../acp/acp-launch-specs'
|
||||
import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions'
|
||||
import {
|
||||
@@ -81,7 +81,7 @@ function withTestSpawnToken<L>(
|
||||
return (input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
spawnToken?: string | null
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
permissionMode?: AgentChatPermissionChoice
|
||||
}) => {
|
||||
const spawnToken = input.spawnToken === undefined ? defaultToken : input.spawnToken
|
||||
return spawnToken === null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Compose shared admission, cwd, environment, visuals and child sealing; agent parts add only agent-specific inputs and never set cwd/sealed env.
|
||||
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { isNativeLocalLocation } from '../../shared/execution-host'
|
||||
import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition'
|
||||
@@ -49,14 +49,14 @@ export type StructuredAgentLaunchBasis = {
|
||||
/** After a missing program, refresh both environment reads together; absent means no retry. */
|
||||
refreshAfterMissingProgram?: (askedAt: number) => Promise<void>
|
||||
/** The permission mode the host decided this start runs with; absent asks. */
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
permissionMode?: AgentChatPermissionChoice
|
||||
}
|
||||
|
||||
export type StructuredLaunchRequest = {
|
||||
identity: AgentSessionJournalIdentity
|
||||
/** The reservation's token, which the seal hands the child. */
|
||||
spawnToken: string
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
permissionMode?: AgentChatPermissionChoice
|
||||
}
|
||||
|
||||
/** What the composition sets on a launch; an agent's part never does. */
|
||||
@@ -135,7 +135,7 @@ async function composedPart<L>(
|
||||
sources: StructuredAgentLaunchSources,
|
||||
part: StructuredAgentLaunchPartResolver<L>,
|
||||
identity: AgentSessionJournalIdentity,
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
permissionMode?: AgentChatPermissionChoice
|
||||
): Promise<StructuredAgentLaunchPart<L> & { cwd: string }> {
|
||||
const record = admittedRecord(definition, sources.store, identity.sessionId)
|
||||
const launchEnv = once(() => sources.resolveAgentLaunchEnv?.(definition.agent) ?? {})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentChatPermissionChoice } from '../../../../shared/agent-chat-permission-mode'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string | number>) =>
|
||||
@@ -82,6 +82,8 @@ vi.mock('@/components/ui/dropdown-menu', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { structuredAgentSessionPermissionDescriptor } from '../../../../shared/structured-agent-session-permission-option'
|
||||
import { createStructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options'
|
||||
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
|
||||
import {
|
||||
NativeChatComposerPermissionPicker,
|
||||
@@ -101,7 +103,7 @@ function props(
|
||||
}
|
||||
}
|
||||
|
||||
function option(mode: AgentChatPermissionMode): HTMLElement {
|
||||
function option(mode: AgentChatPermissionChoice): HTMLElement {
|
||||
return screen.getByRole('radio', {
|
||||
name: (_name, element) => element.getAttribute('data-value') === mode
|
||||
})
|
||||
@@ -204,6 +206,65 @@ describe('NativeChatComposerPermissionPicker', () => {
|
||||
subscribe: () => () => {}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude', 'accept-edits', 'Accept edits'],
|
||||
['codex', 'full-access', 'Full access'],
|
||||
['claude', 'plan', 'Plan mode'],
|
||||
['codex', 'read-only', 'Read only']
|
||||
])(
|
||||
'checks only an offered reported mode for %s/%s and clears on report loss',
|
||||
(agent, mode, label) => {
|
||||
const options = surface()
|
||||
const state = createStructuredAgentSessionOptionState(agent, null)
|
||||
const snapshot = (reportedPermissionMode?: string | null) => {
|
||||
const row = structuredAgentSessionPermissionDescriptor({
|
||||
...state,
|
||||
permissionMode: 'own',
|
||||
permissionOwnSupported: true,
|
||||
reportedPermissionMode
|
||||
})
|
||||
return row ? [row] : []
|
||||
}
|
||||
const { rerender } = render(
|
||||
<NativeChatComposerPermissionPicker surface={options} snapshot={snapshot()} agent={agent} />
|
||||
)
|
||||
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true')
|
||||
for (const report of [mode, null, mode, undefined]) {
|
||||
rerender(
|
||||
<NativeChatComposerPermissionPicker
|
||||
surface={options}
|
||||
snapshot={snapshot(report)}
|
||||
agent={agent}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: `Permissions ${report ? label : 'Permissions'}` })
|
||||
).toBeTruthy()
|
||||
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe(
|
||||
report ? null : 'true'
|
||||
)
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('radio')
|
||||
.filter((row) => row.getAttribute('aria-checked') === 'true')
|
||||
.map((row) => row.getAttribute('data-value'))
|
||||
).toEqual(report && ['accept-edits', 'full-access'].includes(report) ? [report] : [])
|
||||
expect(
|
||||
screen.getAllByRole('radio').some((row) => row.getAttribute('data-value') === 'own')
|
||||
).toBe(false)
|
||||
}
|
||||
rerender(
|
||||
<NativeChatComposerPermissionPicker
|
||||
surface={options}
|
||||
snapshot={snapshot(mode)}
|
||||
agent={agent}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(option('full-access'))
|
||||
expect(options.setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access')
|
||||
}
|
||||
)
|
||||
|
||||
it('renders nothing without a permission descriptor', () => {
|
||||
const { container } = render(
|
||||
<NativeChatComposerPermissionPicker surface={surface()} snapshot={[]} agent="codex" />
|
||||
@@ -211,7 +272,7 @@ describe('NativeChatComposerPermissionPicker', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('writes a new pick through the option surface, and skips the current one', () => {
|
||||
it('writes explicit picks through the option surface even when the report matches', () => {
|
||||
const options = surface()
|
||||
render(
|
||||
<NativeChatComposerPermissionPicker
|
||||
@@ -221,7 +282,7 @@ describe('NativeChatComposerPermissionPicker', () => {
|
||||
/>
|
||||
)
|
||||
fireEvent.click(option('ask'))
|
||||
expect(options.setOption).not.toHaveBeenCalled()
|
||||
expect(options.setOption).toHaveBeenCalledWith('permissionMode', 'ask')
|
||||
// A write that waits behind a starting agent leaves the pill usable.
|
||||
options.setOption.mockImplementation(() => new Promise(() => {}))
|
||||
fireEvent.click(option('full-access'))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { ChevronDown, Settings2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -77,11 +77,12 @@ export function NativeChatPermissionModeItems({
|
||||
}
|
||||
|
||||
export type NativeChatPermissionModePickerProps = {
|
||||
current: AgentChatPermissionMode
|
||||
current: string | undefined
|
||||
modes: readonly AgentChatPermissionMode[]
|
||||
/** Whose descriptions to show; absent describes every agent's. */
|
||||
agent?: string | null
|
||||
disabled?: boolean
|
||||
currentLabel?: string
|
||||
onSelect: (mode: AgentChatPermissionMode) => void
|
||||
}
|
||||
|
||||
@@ -90,9 +91,13 @@ function NativeChatPermissionModePickerInner({
|
||||
modes,
|
||||
agent,
|
||||
disabled = false,
|
||||
onSelect
|
||||
onSelect,
|
||||
currentLabel
|
||||
}: NativeChatPermissionModePickerProps): React.JSX.Element {
|
||||
const title = nativeChatPermissionPickerTitle()
|
||||
const label = isAgentChatPermissionMode(current)
|
||||
? nativeChatPermissionModeLabel(current)
|
||||
: (currentLabel ?? title)
|
||||
// `shrink` overrides the button's shrink-0 so a narrow composer truncates the mode name.
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -106,11 +111,18 @@ function NativeChatPermissionModePickerInner({
|
||||
aria-label={translate(
|
||||
'components.native-chat.composer.pillAccessibleName',
|
||||
'{{value0}} {{value1}}',
|
||||
{ value0: title, value1: nativeChatPermissionModeLabel(current) }
|
||||
{ value0: title, value1: label }
|
||||
)}
|
||||
className="max-w-40 min-w-0 shrink"
|
||||
>
|
||||
<NativeChatPermissionModeName mode={current} truncate />
|
||||
{isAgentChatPermissionMode(current) ? (
|
||||
<NativeChatPermissionModeName mode={current} truncate />
|
||||
) : (
|
||||
<>
|
||||
<Settings2 className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -149,30 +161,27 @@ export function NativeChatComposerPermissionPicker({
|
||||
}): React.JSX.Element | null {
|
||||
const descriptor = snapshot.find((entry) => entry.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID)
|
||||
const current = descriptor?.kind.type === 'select' ? descriptor.kind.currentValue : undefined
|
||||
if (
|
||||
!surface ||
|
||||
!descriptor ||
|
||||
descriptor.kind.type !== 'select' ||
|
||||
!isAgentChatPermissionMode(current)
|
||||
) {
|
||||
if (!surface || !descriptor || descriptor.kind.type !== 'select') {
|
||||
return null
|
||||
}
|
||||
const modes = descriptor.kind.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter(isAgentChatPermissionMode)
|
||||
if (modes.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<NativeChatPermissionModePicker
|
||||
current={current}
|
||||
modes={modes}
|
||||
currentLabel={descriptor.description}
|
||||
agent={agent ?? null}
|
||||
disabled={!descriptor.settable}
|
||||
onSelect={(mode) => {
|
||||
if (mode !== current) {
|
||||
// The pick shows at once, so nothing marks it pending.
|
||||
runSessionOptionSurfaceCall(mode, NO_PENDING_MARK, () =>
|
||||
surface.setOption(AGENT_CHAT_PERMISSION_MODE_OPTION_ID, mode)
|
||||
)
|
||||
}
|
||||
// The report can match a new explicit pick while the chat still follows agent settings.
|
||||
runSessionOptionSurfaceCall(mode, NO_PENDING_MARK, () =>
|
||||
surface.setOption(AGENT_CHAT_PERMISSION_MODE_OPTION_ID, mode)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ call: vi.fn(), confirm: vi.fn() }))
|
||||
let state: StructuredAgentSessionState
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
vi.mock('./use-structured-agent-session-hold', () => ({
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ let publishedTabs: unknown[] = []
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ let items: AgentJournalRenderItem[] = []
|
||||
let backgroundTasks: AgentSessionBackgroundTaskState | undefined
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ let items: AgentJournalRenderItem[] = []
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: mocks.quietRepeatedStop
|
||||
}))
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ let items: AgentJournalRenderItem[] = []
|
||||
let unloadedTurnRevisions: number | undefined
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
readStructuredAgentSessionConversationOutline: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ let items: AgentJournalRenderItem[] = []
|
||||
let submissions: AgentJournalSubmission[] = []
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
readStructuredAgentSessionConversationOutline: mocks.outline,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false),
|
||||
|
||||
+18
-4
@@ -1,3 +1,5 @@
|
||||
import { projectRuntimePermission } from '../../../../shared/structured-agent-session-permission-option'
|
||||
import { useStructuredAgentSessionStatusSummary } from './StructuredAgentSessionStatusBridge'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { AgentSessionConversationCommand } from '../../../../shared/agent-session/agent-session-conversation-command'
|
||||
import type {
|
||||
@@ -22,6 +24,7 @@ import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../sha
|
||||
import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities'
|
||||
import {
|
||||
useStructuredAgentSessionHostCapability,
|
||||
useStructuredAgentSessionOwnPermissionState,
|
||||
structuredAgentSessionHostKey
|
||||
} from '@/runtime/structured-agent-session-host-capability'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
@@ -80,6 +83,7 @@ export function useStructuredAgentSessionOptionState(args: {
|
||||
turnId,
|
||||
worktree
|
||||
} = args
|
||||
const { summary, observation } = useStructuredAgentSessionStatusSummary(sessionId, target)
|
||||
const [conversationSupport, setConversationSupport] = useState<{
|
||||
sessionId: string
|
||||
commands: readonly AgentSessionConversationCommand[]
|
||||
@@ -120,7 +124,7 @@ export function useStructuredAgentSessionOptionState(args: {
|
||||
[]
|
||||
)
|
||||
const hostKey = structuredAgentSessionHostKey(target)
|
||||
const contextKey = JSON.stringify([identity, hostKey, newLaunch, worktree, fence])
|
||||
const contextKey = JSON.stringify([identity, hostKey, newLaunch, worktree, fence, observation])
|
||||
const contextRef = useRef(contextKey)
|
||||
useEffect(() => {
|
||||
contextRef.current = contextKey
|
||||
@@ -145,7 +149,8 @@ export function useStructuredAgentSessionOptionState(args: {
|
||||
...previous,
|
||||
catalogSource: 'host' as const,
|
||||
record: cloneStructuredAgentSessionOptionRecord(previous.record),
|
||||
pendingId: null
|
||||
pendingId: null,
|
||||
reportedPermissionMode: undefined
|
||||
}
|
||||
: seeded
|
||||
const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept
|
||||
@@ -270,14 +275,23 @@ export function useStructuredAgentSessionOptionState(args: {
|
||||
}
|
||||
}
|
||||
}
|
||||
const permissionOwnSupport = useStructuredAgentSessionOwnPermissionState(target)
|
||||
const permissionOwnSupported =
|
||||
permissionOwnSupport === 'unknown' ? null : permissionOwnSupport === 'supported'
|
||||
const takesSendOptions = useStructuredAgentSessionHostCapability(
|
||||
target,
|
||||
AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY
|
||||
)
|
||||
|
||||
return {
|
||||
optionView: (...args: Parameters<typeof structuredAgentSessionOptionView>) => {
|
||||
const view = structuredAgentSessionOptionView(...args)
|
||||
// What the picker shows and a send carries.
|
||||
optionView: (
|
||||
...args: Parameters<typeof structuredAgentSessionOptionView>
|
||||
): StructuredAgentSessionOptionState => {
|
||||
const view = {
|
||||
...projectRuntimePermission(structuredAgentSessionOptionView(...args), summary, fence),
|
||||
permissionOwnSupported
|
||||
}
|
||||
return pendingOptionsRef.current.model === undefined ? view : { ...view, pendingId: 'model' }
|
||||
},
|
||||
takesSendOptions,
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ vi.mock('@/runtime/structured-agent-session-host-capability', async (original) =
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
callStructuredAgentSession: mocks.call,
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() }))
|
||||
}))
|
||||
vi.mock('./native-chat-session-option-settings-write', () => ({
|
||||
enqueueSessionOptionSettingsWrite: vi.fn()
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
vi.mock('./native-chat-session-option-settings-write', () => ({
|
||||
|
||||
+65
-1
@@ -1,3 +1,9 @@
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import {
|
||||
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/agent-session-permission-modes-capability'
|
||||
import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
@@ -8,7 +14,8 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
callStructuredAgentSession: mocks.call,
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() }))
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat-session-option-settings-write', () => ({
|
||||
@@ -72,6 +79,7 @@ function renderReopened(fence: number, agent: 'codex' | 'claude' = 'codex') {
|
||||
|
||||
describe('the permission mode another device picks', () => {
|
||||
beforeEach(() => {
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY])
|
||||
mocks.call.mockReset()
|
||||
resetHostModelCatalogSnapshotsForTests()
|
||||
})
|
||||
@@ -99,6 +107,22 @@ describe('the permission mode another device picks', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('waits for the new child’s report after an own-settings chat restarts', async () => {
|
||||
optionsAnswering('own')
|
||||
const read = mocks.call.getMockImplementation()!
|
||||
mocks.call.mockImplementation(async (...args) => {
|
||||
const answer = await read(...args)
|
||||
return { ...answer, current: { ...answer.current, reportedPermissionMode: 'full-access' } }
|
||||
})
|
||||
const { result, rerender, unmount } = renderReopened(1)
|
||||
const row = () => result.current.optionSnapshot.find((entry) => entry.id === 'permissionMode')
|
||||
await waitFor(() => expect(row()).toMatchObject({ description: 'Full access', settable: true }))
|
||||
mocks.call.mockImplementation(() => new Promise(() => {}))
|
||||
rerender({ fence: 2, stamp: 'model-a|own' })
|
||||
expect(row()).toMatchObject({ description: 'Permissions', settable: false })
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('shows no picker for a host that states no mode', async () => {
|
||||
optionsAnswering(undefined)
|
||||
const { result, unmount } = renderReopened(1)
|
||||
@@ -115,11 +139,51 @@ describe('the permission mode another device picks', () => {
|
||||
|
||||
describe('a permission pick on this device', () => {
|
||||
beforeEach(() => {
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY])
|
||||
mocks.call.mockReset()
|
||||
vi.mocked(mutate).mockReset()
|
||||
resetHostModelCatalogSnapshotsForTests()
|
||||
})
|
||||
|
||||
it('writes an explicit per-chat pick matching the report without changing agent settings', async () => {
|
||||
setLocalRuntimeCapabilitiesForTests([
|
||||
AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY
|
||||
])
|
||||
mocks.call.mockResolvedValue({
|
||||
models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }],
|
||||
current: { model: 'model-a', permissionMode: 'own', reportedPermissionMode: 'full-access' }
|
||||
})
|
||||
vi.mocked(mutate).mockResolvedValue({ options: { permissionMode: 'full-access' } })
|
||||
vi.mocked(enqueueSessionOptionSettingsWrite).mockClear()
|
||||
const { result, unmount } = renderReopened(1)
|
||||
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('full-access'))
|
||||
await act(async () => {
|
||||
expect(await result.current.setStructuredOption('permissionMode', 'full-access')).toBe(true)
|
||||
})
|
||||
expect(mutate).toHaveBeenCalledExactlyOnceWith(
|
||||
'agentSession.setOption',
|
||||
'agentSession.setOption',
|
||||
{
|
||||
key: 'permissionMode',
|
||||
value: 'full-access'
|
||||
}
|
||||
)
|
||||
expect(enqueueSessionOptionSettingsWrite).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('a PR2 client never sends own to a PR1 host', async () => {
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY])
|
||||
optionsAnswering('ask')
|
||||
const { result, unmount } = renderReopened(1)
|
||||
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
|
||||
expect(await result.current.setStructuredOption('permissionMode', 'own')).toBe(false)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
it('shows at once and stays pickable while the write waits behind a start', async () => {
|
||||
optionsAnswering('ask', 'auto')
|
||||
const first = Promise.withResolvers<{ options: Record<string, string> }>()
|
||||
|
||||
+2
@@ -11,10 +11,12 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
vi.mock('@/runtime/local-structured-chats', () => ({ localStructuredChatsInUse: async () => true }))
|
||||
vi.mock('@/runtime/local-runtime-capabilities', () => ({
|
||||
subscribeLocalRuntimeCapabilityObservation: () => () => {},
|
||||
subscribeLocalRuntimeCapabilitiesKnown: () => () => {},
|
||||
ensureLocalRuntimeCapabilities: async () => ['agent-session.model-catalog.saved-only.v1'],
|
||||
readLocalRuntimeCapabilitiesOrUnknown: () => ['agent-session.model-catalog.saved-only.v1']
|
||||
|
||||
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
|
||||
@@ -221,6 +221,9 @@ export function useStructuredAgentSessionOptions(args: {
|
||||
}
|
||||
const encoded = encodeStructuredAgentSessionOptionValue(id, value)
|
||||
if (
|
||||
(id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID &&
|
||||
value === 'own' &&
|
||||
!view.permissionOwnSupported) ||
|
||||
!isCurrentContext() ||
|
||||
!optionCatalog ||
|
||||
encoded === null ||
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false),
|
||||
supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ const items: AgentJournalRenderItem[] = [
|
||||
]
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: vi.fn().mockResolvedValue(null)
|
||||
}))
|
||||
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => {
|
||||
let readState: StructuredAgentSessionState
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toast } }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: async () => true,
|
||||
supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported,
|
||||
|
||||
@@ -23,6 +23,7 @@ let fence = 3
|
||||
let latestTurn: AgentSessionLatestTurn | null | undefined
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false),
|
||||
supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false)
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo
|
||||
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ const items: AgentJournalRenderItem[] = [
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: vi.fn(async () => null),
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ let items: AgentJournalRenderItem[] = []
|
||||
const submissions: AgentJournalSubmission[] = []
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ let nextQueuedMessageId: string | null = null
|
||||
let pendingSends: StructuredAgentSessionPendingSend[] = []
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ const KEPT_SUBMISSION: AgentJournalSubmission = {
|
||||
}
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
callStructuredAgentSession: vi.fn(async () => null),
|
||||
supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user