mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
* Honor saved chat permissions at input admission * Cover fallback and autonomous turns in permission transitions * Preserve host dispatch refusals and verify saved permission publication
668 lines
26 KiB
TypeScript
668 lines
26 KiB
TypeScript
import type { AgentSessionOptionsResult } from '../../../../../src/shared/agent-session/options/agent-session-options-result'
|
|
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
|
|
import {
|
|
OPTIONS,
|
|
BASE,
|
|
deferred,
|
|
accepted,
|
|
optionsClient,
|
|
recordingMutate,
|
|
mountOptions,
|
|
settle
|
|
} from '../../use-mobile-structured-agent-options.test-fixture'
|
|
import { createElement, useState, type ReactNode } from 'react'
|
|
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/agent-session/options/structured-agent-session-permission-option'
|
|
import { createStructuredAgentSessionOptionState } from '../../../../../src/shared/agent-session/options/structured-agent-session-options'
|
|
import type {
|
|
SessionOptionDescriptor,
|
|
SessionOptionSelectChoice
|
|
} from '../../../../../src/shared/native-chat/input/native-chat-session-options'
|
|
import {
|
|
MobileNativeChatSessionOptionPickers,
|
|
type MobileNativeChatPickerPresentation
|
|
} from './MobileNativeChatSessionOptionPickers'
|
|
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
|
|
import {
|
|
findAllByHostType,
|
|
findByHostType
|
|
} from '../../../test-support/react-test-renderer-host-nodes'
|
|
|
|
vi.mock('../../../components/MobileSwitch', () => ({ MobileSwitch: 'Switch' }))
|
|
vi.mock('react-native', () => ({
|
|
AccessibilityInfo: { announceForAccessibilityWithOptions: vi.fn() },
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
Keyboard: { dismiss: vi.fn() },
|
|
Platform: { OS: 'ios' },
|
|
Pressable: 'Pressable',
|
|
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
|
Switch: 'Switch',
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}))
|
|
vi.mock('lucide-react-native', () => ({
|
|
Check: 'Check',
|
|
ChevronDown: 'ChevronDown',
|
|
ChevronLeft: 'ChevronLeft',
|
|
ChevronRight: 'ChevronRight',
|
|
FilePenLine: 'FilePenLine',
|
|
Hand: 'Hand',
|
|
LockKeyhole: 'LockKeyhole',
|
|
ShieldAlert: 'ShieldAlert',
|
|
ShieldCheck: 'ShieldCheck',
|
|
X: 'X'
|
|
}))
|
|
vi.mock('../../../components/bottom-drawer/BottomDrawer', async () => {
|
|
const React = await import('react')
|
|
return {
|
|
BottomDrawer: ({ visible, children }: { visible: boolean; children?: ReactNode }) =>
|
|
visible ? React.createElement('BottomDrawer', { visible }, children) : null
|
|
}
|
|
})
|
|
|
|
const MODEL_DESCRIPTOR: SessionOptionDescriptor = {
|
|
id: 'model',
|
|
label: 'Model',
|
|
category: 'model',
|
|
kind: {
|
|
type: 'select',
|
|
currentValue: 'sonnet',
|
|
choices: [
|
|
{ value: 'sonnet', label: 'Sonnet 5' },
|
|
{ value: 'opus', label: 'Opus 4.8', description: 'Most capable' }
|
|
]
|
|
},
|
|
valueSource: 'reported',
|
|
transport: 'catalog',
|
|
settable: true
|
|
}
|
|
|
|
const EFFORT_DESCRIPTOR: SessionOptionDescriptor = {
|
|
id: 'effort',
|
|
label: 'Effort',
|
|
category: 'thought_level',
|
|
kind: {
|
|
type: 'select',
|
|
currentValue: 'high',
|
|
choices: [
|
|
{ value: 'low', label: 'Low' },
|
|
{ value: 'high', label: 'High' }
|
|
]
|
|
},
|
|
valueSource: 'dispatched',
|
|
transport: 'catalog',
|
|
settable: true
|
|
}
|
|
|
|
const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = {
|
|
id: 'fastMode',
|
|
label: 'Fast mode',
|
|
category: 'mode',
|
|
kind: { type: 'boolean', currentValue: false },
|
|
valueSource: 'reported',
|
|
transport: 'catalog',
|
|
settable: true
|
|
}
|
|
|
|
describe('MobileNativeChatSessionOptionPickers', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
const setOption = vi.fn<MobileNativeChatSessionOptionsController['setOption']>()
|
|
const invokeAction = vi.fn<MobileNativeChatSessionOptionsController['invokeAction']>()
|
|
|
|
const mount = (
|
|
snapshot: SessionOptionDescriptor[],
|
|
isWorking = false,
|
|
requestedPage: string | null = null,
|
|
busy: { pendingId?: string; sendInFlight?: boolean } = {}
|
|
) => {
|
|
const controller: MobileNativeChatSessionOptionsController = {
|
|
snapshot,
|
|
pendingId: busy.pendingId ?? null,
|
|
setOption,
|
|
invokeAction,
|
|
recordCommand: vi.fn()
|
|
}
|
|
function Pickers({ working }: { working: boolean }) {
|
|
const [presentation, setPresentation] = useState<MobileNativeChatPickerPresentation | null>(
|
|
requestedPage ? { page: requestedPage, refocus: false } : null
|
|
)
|
|
return createElement(MobileNativeChatSessionOptionPickers, {
|
|
controller,
|
|
isWorking: working,
|
|
sendInFlight: busy.sendInFlight,
|
|
presentation,
|
|
onDrawerOpen: (page) => setPresentation({ page, refocus: false }),
|
|
onNavigate: (page) => setPresentation((current) => current && { ...current, page })
|
|
})
|
|
}
|
|
act(() => {
|
|
renderer = create(createElement(Pickers, { working: isWorking }))
|
|
})
|
|
return (working: boolean, nextSnapshot = snapshot): void => {
|
|
controller.snapshot = nextSnapshot
|
|
act(() => renderer!.update(createElement(Pickers, { working })))
|
|
}
|
|
}
|
|
|
|
const pill = (name: string) =>
|
|
renderer!.root.find(
|
|
(node) =>
|
|
String(node.type) === 'Pressable' &&
|
|
typeof node.props.accessibilityLabel === 'string' &&
|
|
node.props.accessibilityLabel.startsWith(name)
|
|
)
|
|
|
|
const rowByText = (text: string) => {
|
|
const label = findAllByHostType(findByHostType(renderer!.root, 'BottomDrawer'), 'Text').find(
|
|
(node) => node.props.children === text
|
|
)
|
|
if (!label) {
|
|
throw new Error(`No row labeled ${text}`)
|
|
}
|
|
let parent = label.parent
|
|
while (parent && String(parent.type) !== 'Pressable') {
|
|
parent = parent.parent
|
|
}
|
|
if (!parent) {
|
|
throw new Error(`No pressable row for ${text}`)
|
|
}
|
|
return parent
|
|
}
|
|
|
|
beforeEach(() => {
|
|
Platform.OS = 'ios'
|
|
vi.mocked(AccessibilityInfo.announceForAccessibilityWithOptions).mockClear()
|
|
setOption.mockReset()
|
|
setOption.mockResolvedValue(true)
|
|
invokeAction.mockReset()
|
|
invokeAction.mockResolvedValue(true)
|
|
})
|
|
afterEach(() => {
|
|
act(() => {
|
|
renderer?.unmount()
|
|
})
|
|
renderer = null
|
|
})
|
|
|
|
it('consumes a refused page without replay, then permits a fresh open and permission pick', async () => {
|
|
const placeholder: SessionOptionDescriptor = {
|
|
...MODEL_DESCRIPTOR,
|
|
kind: { type: 'select', choices: [] },
|
|
valueSource: 'unknown',
|
|
transport: 'agent-session',
|
|
settable: false
|
|
}
|
|
const update = mount([placeholder], true, 'model')
|
|
expect(pill('Model').props.disabled).toBe(true)
|
|
act(() => pill('Model').props.onPress())
|
|
expect(findAllByHostType(renderer!.root, 'BottomDrawer')).toHaveLength(0)
|
|
update(false, [placeholder, PERMISSION_DESCRIPTOR])
|
|
expect(pill('Model').props.disabled).toBe(false)
|
|
expect(findAllByHostType(renderer!.root, 'BottomDrawer')).toHaveLength(0)
|
|
await act(async () => pill('Model').props.onPress())
|
|
await act(async () => rowByText('Permissions').props.onPress())
|
|
await act(async () => rowByText('Full access').props.onPress())
|
|
expect(setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access')
|
|
})
|
|
|
|
it('picks the shown Codex default during a reply and captures it only for the next message', async () => {
|
|
const pending = deferred<AgentSessionOptionsResult>()
|
|
let reads = 0
|
|
const rpc = optionsClient(() =>
|
|
++reads === 1
|
|
? Promise.resolve({
|
|
...OPTIONS,
|
|
listedModelIds: OPTIONS.models.map((row) => row.id),
|
|
current: { model: 'gpt-fast', acceptedInputSources: { model: 'saved' }, defaulted: [] }
|
|
})
|
|
: pending.promise
|
|
)
|
|
const { calls, mutate } = recordingMutate(async () =>
|
|
accepted({ key: 'effort', value: 'low', options: { effort: 'low' } }, true)
|
|
)
|
|
const view = await mountOptions({ ...BASE, client: rpc.client, mutate, sendSelection: true })
|
|
const snapshot = () => [...view.current().optionSnapshot]
|
|
try {
|
|
const update = mount(snapshot(), true)
|
|
const running = view.current().sendSelection()
|
|
expect(running).toEqual({ selection: { model: 'gpt-fast' } })
|
|
await act(async () => pill('Model').props.onPress())
|
|
setOption.mockImplementation(view.current().setStructuredOption)
|
|
await act(async () => pill('Low').props.onPress())
|
|
await settle()
|
|
expect(calls.map((call) => call.fields)).toEqual([{ key: 'effort', value: 'low' }])
|
|
expect(view.current().sendSelection()).toEqual({
|
|
selection: { model: 'gpt-fast', effort: 'low' }
|
|
})
|
|
expect(running).toEqual({ selection: { model: 'gpt-fast' } })
|
|
update(true, snapshot())
|
|
await act(async () => pill('Low').props.onPress())
|
|
expect(setOption).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
await view.unmount()
|
|
}
|
|
})
|
|
|
|
it('renders actual settled permission/reset picks without busy state and locks ordinary pending model work', async () => {
|
|
const idle = deferred<AgentSessionOptionsResult>()
|
|
let reads = 0
|
|
const rpc = optionsClient(() =>
|
|
++reads === 1
|
|
? Promise.resolve({ ...OPTIONS, current: { ...OPTIONS.current, permissionMode: 'ask' } })
|
|
: idle.promise
|
|
)
|
|
const replies = Array.from({ length: 3 }, () => deferred<ReturnType<typeof accepted>>())
|
|
const { calls, mutate } = recordingMutate((index) => replies[index].promise)
|
|
const view = await mountOptions({ ...BASE, client: rpc.client, mutate, permissionOwn: true })
|
|
function ActualPickers() {
|
|
const options = view.current()
|
|
const { nativeChatSessionOptions } = useMobileNativeChatSessionOptionController({
|
|
client: rpc.client,
|
|
activeChatStructured: true,
|
|
activeSessionTabId: BASE.sessionId,
|
|
agent: 'codex',
|
|
dispatchCommand: async () => 'accepted',
|
|
hostId: 'phone-picker',
|
|
isTabChatView: () => true,
|
|
isWorking: true,
|
|
reportedModel: null,
|
|
structured: {
|
|
snapshot: options.optionSnapshot,
|
|
pendingId: options.pendingOptionId,
|
|
setOption: options.setStructuredOption,
|
|
invokeAction: options.invokeStructuredOption
|
|
},
|
|
toggleTabChatView: () => {},
|
|
worktreeId: 'workspace-1'
|
|
})
|
|
return createElement(MobileNativeChatSessionOptionPickers, {
|
|
...nativeChatSessionOptions!,
|
|
presentation: { page: 'model', refocus: false },
|
|
onDrawerOpen: () => {},
|
|
onNavigate: () => {}
|
|
})
|
|
}
|
|
try {
|
|
await act(async () => {
|
|
renderer = create(createElement(ActualPickers))
|
|
})
|
|
for (const [index, value] of ['full-access', 'own'].entries()) {
|
|
let pick!: Promise<boolean>
|
|
act(() => {
|
|
pick = view.current().setStructuredOption('permissionMode', value)
|
|
})
|
|
expect(calls[index]?.fields).toEqual({ key: 'permissionMode', value })
|
|
expect(view.current().pendingOptionId).toBeNull()
|
|
await act(async () => renderer!.update(createElement(ActualPickers)))
|
|
expect(renderer!.root.findAllByProps({ children: 'Applying…' })).toHaveLength(0)
|
|
expect(rowByText('GPT Fast').props.accessibilityState?.disabled).toBe(false)
|
|
await act(async () =>
|
|
replies[index].resolve(
|
|
accepted({ key: 'permissionMode', value, options: { permissionMode: value } }, true)
|
|
)
|
|
)
|
|
await expect(pick).resolves.toBe(true)
|
|
expect(view.current().pendingOptionId).toBeNull()
|
|
await act(async () => renderer!.update(createElement(ActualPickers)))
|
|
expect(renderer!.root.findAllByProps({ children: 'Applying…' })).toHaveLength(0)
|
|
expect(rowByText('GPT Fast').props.accessibilityState?.disabled).toBe(false)
|
|
}
|
|
act(() => {
|
|
void view.current().setStructuredOption('model', 'gpt-fast')
|
|
})
|
|
await act(async () => renderer!.update(createElement(ActualPickers)))
|
|
expect(rowByText('GPT Live').props.accessibilityState?.disabled).toBe(true)
|
|
expect(renderer!.root.findAllByProps({ children: 'Applying…' })).toHaveLength(1)
|
|
expect(calls.map((call) => call.fields)).toEqual([
|
|
{ key: 'permissionMode', value: 'full-access' },
|
|
{ key: 'permissionMode', value: 'own' },
|
|
{ key: 'model', value: 'gpt-fast' }
|
|
])
|
|
} finally {
|
|
await view.unmount()
|
|
}
|
|
})
|
|
|
|
it('renders nothing without a model descriptor', () => {
|
|
mount([])
|
|
expect(renderer!.toJSON()).toBeNull()
|
|
})
|
|
|
|
it('shows the current model and effort in one pill', () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
expect(pill('Model').props).toMatchObject({
|
|
accessibilityLabel: 'Model, Sonnet 5 High',
|
|
disabled: false
|
|
})
|
|
})
|
|
|
|
it('opens the model sheet and applies a picked model', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(findByHostType(renderer!.root, 'BottomDrawer').props.visible).toBe(true)
|
|
await act(async () => rowByText('Opus 4.8').props.onPress())
|
|
expect(setOption).toHaveBeenCalledWith('model', 'opus')
|
|
})
|
|
|
|
it('picks effort in place on the model sheet', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
await act(async () => pill('Low').props.onPress())
|
|
expect(setOption).toHaveBeenCalledWith('effort', 'low')
|
|
})
|
|
|
|
it('opens an option that is not effort on a page of its own from the model sheet', async () => {
|
|
mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, category: 'model_config' }])
|
|
await act(async () => pill('Model').props.onPress())
|
|
await act(async () => rowByText('Effort').props.onPress())
|
|
await act(async () => rowByText('Low').props.onPress())
|
|
expect(setOption).toHaveBeenCalledWith('effort', 'low')
|
|
})
|
|
|
|
it('flips a yes-or-no option in place', async () => {
|
|
mount([MODEL_DESCRIPTOR, FAST_MODE_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
const toggle = renderer!.root.findByProps({ accessibilityLabel: 'Fast mode' })
|
|
await act(async () => toggle.props.onValueChange(true))
|
|
expect(setOption).toHaveBeenCalledWith('fastMode', true)
|
|
})
|
|
|
|
it('announces choice selection and disabled state', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
expect(pill('Model').props).toMatchObject({
|
|
accessibilityRole: 'button',
|
|
accessibilityState: { disabled: false }
|
|
})
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(rowByText('Sonnet 5').props).toMatchObject({
|
|
accessibilityRole: 'radio',
|
|
accessibilityState: { checked: true, disabled: false }
|
|
})
|
|
expect(rowByText('Opus 4.8').props.accessibilityState?.checked).toBe(false)
|
|
})
|
|
|
|
it('does not dispatch when re-picking the tracked value', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
await act(async () => rowByText('Sonnet 5').props.onPress())
|
|
expect(setOption).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders agent-picker descriptors as a single action row', async () => {
|
|
mount([
|
|
{
|
|
...MODEL_DESCRIPTOR,
|
|
kind: { type: 'select', choices: [] },
|
|
valueSource: 'unknown',
|
|
transport: 'catalog',
|
|
action: { type: 'agent-picker' }
|
|
}
|
|
])
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(rowByText('Choose in agent picker…').props.accessibilityRole).toBe('button')
|
|
await act(async () => rowByText('Choose in agent picker…').props.onPress())
|
|
expect(invokeAction).toHaveBeenCalledWith('model')
|
|
})
|
|
|
|
// Terminal settings are inferred from screen parsing; structured settings are confirmed each turn and need no uncertainty caption.
|
|
it.each([
|
|
{ transport: 'catalog' as const, caption: true },
|
|
{ transport: 'agent-session' as const, caption: false }
|
|
])('captions a dispatched value only on the terminal transport', async (scenario) => {
|
|
mount([
|
|
MODEL_DESCRIPTOR,
|
|
{ ...EFFORT_DESCRIPTOR, valueSource: 'dispatched', transport: scenario.transport }
|
|
])
|
|
await act(async () => pill('Model').props.onPress())
|
|
const captions = findAllByHostType(renderer!.root, 'Text').filter(
|
|
(node) => node.props.children === 'Sent to the agent — not confirmed'
|
|
)
|
|
expect(captions.length > 0).toBe(scenario.caption)
|
|
})
|
|
|
|
it.each(['catalog', 'agent-session'] as const)(
|
|
'does not caption a reported value on the %s transport',
|
|
async (transport) => {
|
|
mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, valueSource: 'reported', transport }])
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(
|
|
findAllByHostType(renderer!.root, 'Text').some(
|
|
(node) => node.props.children === 'Sent to the agent — not confirmed'
|
|
)
|
|
).toBe(false)
|
|
}
|
|
)
|
|
|
|
it.each(['catalog', 'agent-session'] as const)(
|
|
'uses the %s transport to lock reply-time model, segment, toggle and detail choices',
|
|
async (transport) => {
|
|
const tier: SessionOptionDescriptor = {
|
|
...EFFORT_DESCRIPTOR,
|
|
id: 'serviceTier',
|
|
label: 'Speed',
|
|
category: 'model_config'
|
|
}
|
|
mount(
|
|
[
|
|
...[MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR, FAST_MODE_DESCRIPTOR, tier].map(
|
|
(descriptor) => ({ ...descriptor, transport })
|
|
),
|
|
PERMISSION_DESCRIPTOR
|
|
],
|
|
true
|
|
)
|
|
const locked = transport === 'catalog'
|
|
expect(pill('Model').props).toMatchObject({ disabled: false })
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(rowByText('Opus 4.8').props.accessibilityState?.disabled).toBe(locked)
|
|
expect(pill('Low').props.accessibilityState?.disabled).toBe(locked)
|
|
expect(findByHostType(renderer!.root, 'Switch').props.disabled).toBe(locked)
|
|
expect(rowByText('Speed').props.accessibilityState?.disabled).toBe(locked)
|
|
expect(rowByText('Permissions').props.accessibilityState?.disabled).toBe(false)
|
|
expect(renderer!.root.findAllByProps({ children: 'Agent is working' })).toHaveLength(
|
|
locked ? 1 : 0
|
|
)
|
|
expect(AccessibilityInfo.announceForAccessibilityWithOptions).toHaveBeenCalledTimes(
|
|
locked ? 1 : 0
|
|
)
|
|
if (!locked) {
|
|
await act(async () => rowByText('Opus 4.8').props.onPress())
|
|
await act(async () => pill('Low').props.onPress())
|
|
await act(async () => findByHostType(renderer!.root, 'Switch').props.onValueChange(true))
|
|
expect(setOption.mock.calls).toEqual([
|
|
['model', 'opus'],
|
|
['effort', 'low'],
|
|
['fastMode', true]
|
|
])
|
|
await act(async () => rowByText('Speed').props.onPress())
|
|
expect(rowByText('Low').props.accessibilityState?.disabled).toBe(false)
|
|
await act(async () => rowByText('Low').props.onPress())
|
|
expect(setOption).toHaveBeenLastCalledWith('serviceTier', 'low')
|
|
}
|
|
await act(async () => rowByText('Permissions').props.onPress())
|
|
expect(renderer!.root.findAllByProps({ children: 'Agent is working' })).toHaveLength(0)
|
|
}
|
|
)
|
|
|
|
it.each([{ pendingId: 'model' }, { sendInFlight: true }])(
|
|
'keeps structured opening and choices locked while busy: %j',
|
|
async (busy) => {
|
|
mount(
|
|
[MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR, FAST_MODE_DESCRIPTOR].map((descriptor) => ({
|
|
...descriptor,
|
|
transport: 'agent-session'
|
|
})),
|
|
true,
|
|
'model',
|
|
busy
|
|
)
|
|
expect(pill('Model').props.disabled).toBe(true)
|
|
expect(rowByText('Opus 4.8').props.accessibilityState?.disabled).toBe(true)
|
|
expect(pill('Low').props.accessibilityState?.disabled).toBe(true)
|
|
expect(findByHostType(renderer!.root, 'Switch').props.disabled).toBe(true)
|
|
}
|
|
)
|
|
|
|
it.each(['ios', 'android'] as const)(
|
|
'announces the working notice without queuing only when it appears on %s',
|
|
async (platform) => {
|
|
Platform.OS = platform
|
|
const updateWorking = mount([MODEL_DESCRIPTOR, PERMISSION_DESCRIPTOR])
|
|
const announce = vi.mocked(AccessibilityInfo.announceForAccessibilityWithOptions)
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(announce).not.toHaveBeenCalled()
|
|
|
|
updateWorking(true)
|
|
const notice = renderer!.root.findByProps({ accessibilityLiveRegion: 'polite' })
|
|
expect(notice.findByProps({ children: 'Agent is working' })).toBeDefined()
|
|
if (platform === 'ios') {
|
|
expect(announce).toHaveBeenCalledExactlyOnceWith(
|
|
'Agent is working. Model settings unlock when this turn finishes.',
|
|
{ queue: false }
|
|
)
|
|
} else {
|
|
expect(announce).not.toHaveBeenCalled()
|
|
}
|
|
|
|
updateWorking(true)
|
|
updateWorking(false)
|
|
await act(async () => rowByText('Permissions').props.onPress())
|
|
updateWorking(true)
|
|
expect(renderer!.root.findAllByProps({ children: 'Agent is working' })).toHaveLength(0)
|
|
expect(announce).toHaveBeenCalledTimes(platform === 'ios' ? 1 : 0)
|
|
|
|
await act(async () => pill('Back to models').props.onPress())
|
|
expect(announce).toHaveBeenCalledTimes(platform === 'ios' ? 2 : 0)
|
|
await act(async () => pill('Done').props.onPress())
|
|
updateWorking(false)
|
|
updateWorking(true)
|
|
expect(announce).toHaveBeenCalledTimes(platform === 'ios' ? 2 : 0)
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(announce).toHaveBeenCalledTimes(platform === 'ios' ? 3 : 0)
|
|
}
|
|
)
|
|
|
|
it('keeps the permission mode in the model sheet and warns on the pill at full access', async () => {
|
|
mount([
|
|
MODEL_DESCRIPTOR,
|
|
{
|
|
...PERMISSION_DESCRIPTOR,
|
|
kind: { type: 'select', currentValue: 'full-access', choices: PERMISSION_CHOICES }
|
|
}
|
|
])
|
|
expect(pill('Model').props.accessibilityLabel).toBe('Model, Sonnet 5, full access')
|
|
setOption.mockImplementation(() => new Promise(() => {}))
|
|
await act(async () => pill('Model').props.onPress())
|
|
await act(async () => rowByText('Permissions').props.onPress())
|
|
await act(async () => rowByText('Ask for approval').props.onPress())
|
|
expect(setOption).toHaveBeenCalledWith('permissionMode', 'ask')
|
|
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']
|
|
])(
|
|
'keeps Own selected despite the nullable %s/%s report without adding an Own row',
|
|
async (agent, mode) => {
|
|
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: false,
|
|
accessibilityLabel: "Permissions, Agents' own settings"
|
|
})
|
|
for (const report of [mode, null, mode, undefined]) {
|
|
update(false, snapshot(report))
|
|
expect(pill('Permissions').props).toMatchObject({
|
|
disabled: false,
|
|
accessibilityLabel: "Permissions, Agents' own settings"
|
|
})
|
|
}
|
|
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([])
|
|
expect(
|
|
rows.some((row) =>
|
|
findAllByHostType(row, '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'],
|
|
['host', "Host's Agent Permissions", null, 'ask', 'Ask for approval']
|
|
] as const)(
|
|
'names the permission-only %s pill and closes its labeled sheet on a pick',
|
|
async (value, label, icon, nextValue, nextLabel) => {
|
|
const choices = [
|
|
{ value, label },
|
|
{ value: nextValue, label: nextLabel }
|
|
]
|
|
const kind = { type: 'select' as const, currentValue: value, choices }
|
|
mount([{ ...PERMISSION_DESCRIPTOR, kind }], true)
|
|
expect(pill('Permissions').props).toMatchObject({
|
|
accessibilityLabel: `Permissions, ${label}`,
|
|
disabled: false
|
|
})
|
|
expect(findAllByHostType(renderer!.root, 'Text')).toHaveLength(icon ? 0 : 2)
|
|
expect(findAllByHostType(renderer!.root, 'ShieldAlert')).toHaveLength(
|
|
value === 'full-access' ? 1 : 0
|
|
)
|
|
if (icon) {
|
|
expect(findAllByHostType(renderer!.root, icon)).toHaveLength(1)
|
|
expect(findByHostType(renderer!.root, icon).props.color).toBe(
|
|
value === 'full-access' ? colors.statusAmber : colors.textSecondary
|
|
)
|
|
}
|
|
setOption.mockImplementation(() => new Promise(() => {}))
|
|
await act(async () => pill('Permissions').props.onPress())
|
|
expect(rowByText(nextLabel).props.accessibilityRole).toBe('radio')
|
|
await act(async () => rowByText(nextLabel).props.onPress())
|
|
expect(setOption).toHaveBeenCalledWith('permissionMode', nextValue)
|
|
expect(findAllByHostType(renderer!.root, 'BottomDrawer')).toEqual([])
|
|
}
|
|
)
|
|
})
|
|
|
|
const PERMISSION_CHOICES: SessionOptionSelectChoice[] = [
|
|
{ value: 'ask', label: 'Ask for approval' },
|
|
{ value: 'full-access', label: 'Full access' }
|
|
]
|
|
|
|
const PERMISSION_DESCRIPTOR: SessionOptionDescriptor = {
|
|
id: 'permissionMode',
|
|
label: 'Permissions',
|
|
category: 'mode',
|
|
kind: { type: 'select', currentValue: 'ask', choices: PERMISSION_CHOICES },
|
|
valueSource: 'applied',
|
|
transport: 'agent-session',
|
|
settable: true
|
|
}
|