Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import {
AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY
} from '../../../src/shared/agent-session-create-capabilities'
Expand Down Expand Up @@ -43,6 +44,8 @@ export type StructuredAgentSessionHostSupport = {
// Hosts predating mobile access refuse this feed even if they implement it.
statusFeed: boolean
quietRepeatedStop: boolean
/** A send may carry the model and effort its turn runs on. */
sendSelection?: boolean
sendOptions: boolean
/** Older hosts refuse a send body carrying picked skill paths. */
sendSkills?: boolean
Expand Down Expand Up @@ -78,6 +81,7 @@ export function structuredAgentSessionHostSupport(
),
statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY),
sendSelection: capabilities.includes(AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY),
sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY),
sendSkills: capabilities.includes(AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY),
discoverWorkspaceSkills: capabilities.includes(SKILL_DISCOVER_WORKSPACE_CAPABILITY),
Expand Down
5 changes: 4 additions & 1 deletion mobile/src/session/mobile-structured-agent-session-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
skills?: readonly AgentJournalSkillRef[]
/** Sent only when the host advertises `agent-session.queued-messages.v1`. */
delivery?: 'queue-if-active'
/** Sent only when the host advertises `agent-session.send-selection.v1`. */
selection?: Readonly<Record<string, string>>
/** Sent only when the host advertises `agent-session.send-options.v1`. */
options?: Readonly<Record<string, string>>
deadline?: number
Expand Down Expand Up @@ -59,7 +61,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
fields: {
body,
...(input.delivery ? { delivery: input.delivery } : {}),
...(input.options ? { options: input.options } : {})
...(input.options ? { options: input.options } : {}),
...(input.selection !== undefined ? { selection: input.selection } : {})
},
clientOperationId: input.clientMessageId,
timeoutMs
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import type { AgentSessionOptionsResult } from '../../../../../src/shared/agent-session-wire'
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
import {
OPTIONS,
BASE,
deferred,
accepted,
optionsClient,
recordingMutate,
mountOptions
} 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'
Expand Down Expand Up @@ -168,6 +179,86 @@ describe('MobileNativeChatSessionOptionPickers', () => {
renderer = null
})

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: false,
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(findAllByHostType(renderer!.root, 'ActivityIndicator')).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(findAllByHostType(renderer!.root, 'ActivityIndicator')).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(findAllByHostType(renderer!.root, 'ActivityIndicator')).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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ async function mount(initial: Props) {
fence: props.fence,
mutate,
turnId: null,
hostSupport: { permissionModes: true, coherentModelCatalog: true, statusFeed: false }
hostSupport: {
sendOptions: false,
permissionModes: true,
coherentModelCatalog: true,
statusFeed: false
}
})
latest = options
frames.push(modelChoices(options.optionSnapshot))
Expand Down
Loading
Loading