From 9202192f104bdd88ca994751c55e7cfec25e9093 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:37:59 -0700 Subject: [PATCH] fix: restore structured chat parity --- .../src/session/MobileNativeChatComposer.tsx | 7 +- .../MobileNativeChatSessionOptionPickers.tsx | 15 +- .../MobileStructuredAgentSessionView.test.tsx | 111 ++++++++++++++ .../MobileStructuredAgentSessionView.tsx | 26 +++- ...mobile-structured-composer-command.test.ts | 87 +++++++++++ .../mobile-structured-composer-command.ts | 89 ++++++++++++ ...obile-structured-session-option-catalog.ts | 40 ++++++ .../use-mobile-native-chat-session-options.ts | 3 + .../use-mobile-structured-session-entry.ts | 2 + ...-mobile-structured-session-options.test.ts | 121 ++++++++++++---- .../use-mobile-structured-session-options.ts | 83 +++++++++-- .../codex-structured-session-adapter.test.ts | 10 +- .../codex/codex-structured-session-adapter.ts | 43 +++--- .../codex/codex-structured-session-close.ts | 16 +-- .../codex-structured-session-options.test.ts | 91 ++++++++++++ .../codex/codex-structured-session-options.ts | 135 ++++++++++++++++++ .../codex/codex-structured-session-state.ts | 1 + .../codex/codex-structured-thread-open.ts | 16 ++- src/main/index.ts | 5 + .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-host.ts | 53 +++---- .../structured-agent-session-read-restore.ts | 29 +++- ...tructured-agent-session-restart-restore.ts | 53 +++++++ ...ctured-agent-session-create-intent.test.ts | 55 +++++++ src/main/runtime/orca-runtime.ts | 20 ++- .../structured-agent-session-schemas.ts | 2 + .../methods/structured-agent-session.test.ts | 6 +- .../rpc/methods/structured-agent-session.ts | 6 + src/main/runtime/runtime-rpc.ts | 1 + src/shared/agent-session-wire.ts | 25 ++++ ...ss-version-agent-session-wire.unit.test.ts | 45 +++--- 31 files changed, 1062 insertions(+), 136 deletions(-) create mode 100644 mobile/src/session/MobileStructuredAgentSessionView.test.tsx create mode 100644 mobile/src/session/mobile-structured-composer-command.test.ts create mode 100644 mobile/src/session/mobile-structured-composer-command.ts create mode 100644 mobile/src/session/mobile-structured-session-option-catalog.ts create mode 100644 src/main/codex/codex-structured-session-options.test.ts create mode 100644 src/main/codex/codex-structured-session-options.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts create mode 100644 src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 9b289b4b6e8..e404e4cfbc6 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -11,6 +11,7 @@ import { import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles' +import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' import { applyAutocomplete, detectAutocompleteTrigger, @@ -38,6 +39,7 @@ type Props = { onSend: (text: string) => Promise /** Active tab's agent — the slash autocomplete serves its command catalog. */ agent?: string | null + slashCommands?: readonly SlashCommandSuggestion[] /** Model/session-option pickers shown in the composer action row; null when * the agent has no session-option catalog. */ sessionOptions?: MobileNativeChatSessionOptionPickersProps | null @@ -64,6 +66,7 @@ export function MobileNativeChatComposer({ onChangeText, onSend, agent, + slashCommands, sessionOptions, onAttachImage, attachments = NO_ATTACHMENTS, @@ -105,7 +108,7 @@ export function MobileNativeChatComposer({ return [] } if (trigger.kind === 'slash') { - const commands = agent ? getVerifiedNativeChatCommands(agent) : [] + const commands = slashCommands ?? (agent ? getVerifiedNativeChatCommands(agent) : []) // Why: Codex's catalog is 45 commands and this list is a plain ScrollView // (~5 rows visible), so an uncapped `/` would mount every row and // re-reconcile them on each streaming tick right above the transcript. @@ -118,7 +121,7 @@ export function MobileNativeChatComposer({ kind: 'file' as const, path })) - }, [trigger, filePaths, agent]) + }, [trigger, filePaths, agent, slashCommands]) useEffect(() => { if (trigger?.kind === 'file') { diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index bfa5244a398..88367209837 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronLeft, X } from 'lucide-react-native' import { BottomDrawer } from '../components/BottomDrawer' @@ -44,11 +44,22 @@ export function MobileNativeChatSessionOptionPickers({ const { snapshot, pendingId } = controller const model = snapshot.find((descriptor) => descriptor.category === 'model') const options = sortNativeChatSessionOptions(snapshot) + const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId) + + useEffect(() => { + const request = controller.pickerRequest + if (!request || !snapshot.some((descriptor) => descriptor.id === request.id)) { + return + } + Keyboard.dismiss() + setOpenDescriptorId(request.id) + controller.dismissPickerRequest?.(request.token) + }, [controller, snapshot]) + if (!model) { return null } const disabled = isWorking || pendingId !== null || sendInFlight - const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId) const modelView = activeDescriptor?.id === model.id const modelLabel = mobileModelPillLabel(model) const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null diff --git a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx new file mode 100644 index 00000000000..4c9e809a371 --- /dev/null +++ b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx @@ -0,0 +1,111 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' +import { MobileStructuredAgentSessionView } from './MobileStructuredAgentSessionView' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + FlatList: 'FlatList', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ Square: 'Square' })) +vi.mock('./MobileNativeChatComposer', () => ({ + MobileNativeChatComposer: 'MobileNativeChatComposer' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'MobileNativeChatMessage' })) +vi.mock('./MobileStructuredPromptCard', () => ({ + MobileStructuredPromptCard: 'MobileStructuredPromptCard' +})) +vi.mock('./mobile-structured-agent-session-view-styles', () => ({ + styles: new Proxy({}, { get: () => ({}) }) +})) + +function options(): MobileNativeChatSessionOptionsController { + return { + snapshot: [ + { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'gpt-live', + choices: [{ value: 'gpt-live', label: 'GPT Live' }] + }, + valueSource: 'reported', + settable: true + } + ], + pendingId: null, + setOption: vi.fn(async () => true), + invokeAction: vi.fn(async () => true), + recordCommand: vi.fn() + } +} + +describe('MobileStructuredAgentSessionView command seam', () => { + let renderer: ReactTestRenderer | null = null + const onSend = vi.fn(async () => true) + let controller: MobileNativeChatSessionOptionsController + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + onSend.mockClear() + controller = options() + act(() => { + renderer = create( + createElement(MobileStructuredAgentSessionView, { + items: [], + status: 'ready', + hasOlder: false, + loadingOlder: false, + onLoadOlder: async () => false, + outbox: [], + onSend, + onTakeQueuedForEdit: async () => null, + onRetry: async () => {}, + onRespondToPrompt: async () => true, + sessionOptions: controller, + attachments: [], + isAttaching: false, + onAttachImage: () => {}, + onRemoveAttachment: () => {}, + onCancel: async () => true + }) + ) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function submit(text: string): Promise { + const composer = renderer!.root.findByType('MobileNativeChatComposer') + act(() => composer.props.onChangeText(text)) + await act(async () => { + await renderer!.root.findByType('MobileNativeChatComposer').props.onSend(text) + }) + } + + it('opens the structured model picker without issuing a chat send', async () => { + await submit('/model') + + expect(controller.invokeAction).toHaveBeenCalledWith('model') + expect(onSend).not.toHaveBeenCalled() + }) + + it('renders an explicit refusal for unsupported advertised commands', async () => { + await submit('/review') + + expect(onSend).not.toHaveBeenCalled() + expect( + renderer!.root.findByProps({ accessibilityRole: 'alert' }).findByType('Text').children + ).toEqual(['/review is not available in chat sessions.']) + }) +}) diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx index 6d31233aa2f..a8733f55306 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { ActivityIndicator, FlatList, @@ -37,6 +37,11 @@ import { } from './mobile-structured-history-pagination' import { styles } from './mobile-structured-agent-session-view-styles' import { useMobileStructuredComposerState } from './use-mobile-structured-composer-state' +import { + dispatchMobileStructuredComposerCommand, + isMobileStructuredComposerCommand, + MOBILE_STRUCTURED_SLASH_COMMANDS +} from './mobile-structured-composer-command' type Props = { items: AgentJournalRenderItem[] @@ -67,6 +72,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen const androidAnchorOffsetRef = useRef(0) const { composerText, setComposerText, restored, setRestored } = useMobileStructuredComposerState() + const [commandError, setCommandError] = useState(null) const turnId = activeMobileStructuredTurnId(props.items) const allAttachments = [...restored, ...props.attachments] const rows = useMemo( @@ -191,15 +197,28 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen ) : null} - {props.writeError ? ( + {commandError || props.writeError ? ( - {props.writeError} + {commandError ?? props.writeError} ) : null} { + if (allAttachments.length > 0 && isMobileStructuredComposerCommand(text)) { + setCommandError('Remove attachments before using a chat-session command.') + return false + } + const command = await dispatchMobileStructuredComposerCommand(text, props.sessionOptions) + if (command.handled) { + setCommandError(command.error) + if (command.accepted) { + setComposerText('') + } + return command.accepted + } + setCommandError(null) const accepted = await props.onSend(text, restored) if (accepted) { setComposerText('') @@ -208,6 +227,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen return accepted }} agent="codex" + slashCommands={MOBILE_STRUCTURED_SLASH_COMMANDS} sessionOptions={{ controller: props.sessionOptions, isWorking: false }} onAttachImage={props.onAttachImage} attachments={allAttachments} diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts new file mode 100644 index 00000000000..f5c48900564 --- /dev/null +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' +import { dispatchMobileStructuredComposerCommand } from './mobile-structured-composer-command' + +function controller(): MobileNativeChatSessionOptionsController { + return { + snapshot: [ + { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'gpt-live', + choices: [ + { value: 'gpt-live', label: 'GPT Live' }, + { value: 'gpt-next', label: 'GPT Next' } + ] + }, + valueSource: 'reported', + settable: true + }, + { + id: 'effort', + label: 'Reasoning effort', + kind: { + type: 'select', + currentValue: 'medium', + choices: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + }, + valueSource: 'reported', + settable: true + } + ], + pendingId: null, + setOption: vi.fn(async () => true), + invokeAction: vi.fn(async () => true), + recordCommand: vi.fn() + } +} + +describe('structured composer slash dispatch', () => { + it('opens live model and effort pickers instead of sending command text', async () => { + const options = controller() + + expect(await dispatchMobileStructuredComposerCommand('/model', options)).toEqual({ + handled: true, + accepted: true, + error: null + }) + expect(await dispatchMobileStructuredComposerCommand('/effort', options)).toEqual({ + handled: true, + accepted: true, + error: null + }) + expect(options.invokeAction).toHaveBeenNthCalledWith(1, 'model') + expect(options.invokeAction).toHaveBeenNthCalledWith(2, 'effort') + }) + + it('applies command arguments through structured setOption', async () => { + const options = controller() + + await dispatchMobileStructuredComposerCommand('/model GPT Next', options) + await dispatchMobileStructuredComposerCommand('/effort high', options) + + expect(options.setOption).toHaveBeenNthCalledWith(1, 'model', 'gpt-next') + expect(options.setOption).toHaveBeenNthCalledWith(2, 'effort', 'high') + }) + + it('visibly refuses every other advertised Codex command', async () => { + const result = await dispatchMobileStructuredComposerCommand('/review', controller()) + expect(result).toEqual({ + handled: true, + accepted: true, + error: '/review is not available in chat sessions.' + }) + }) + + it('leaves unadvertised slash text available as an ordinary prompt', async () => { + await expect( + dispatchMobileStructuredComposerCommand('/not-a-catalog-command', controller()) + ).resolves.toEqual({ handled: false, accepted: false, error: null }) + }) +}) diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts new file mode 100644 index 00000000000..6c36fb4ce0a --- /dev/null +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -0,0 +1,89 @@ +import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles' +import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' +import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' + +const EFFORT_COMMAND: SlashCommandSuggestion = { + name: 'effort', + description: 'Choose reasoning effort' +} + +export const MOBILE_STRUCTURED_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [ + ...getVerifiedNativeChatCommands('codex').slice(0, 1), + EFFORT_COMMAND, + ...getVerifiedNativeChatCommands('codex').slice(1) +] + +export type MobileStructuredCommandOutcome = { + handled: boolean + accepted: boolean + error: string | null +} + +function commandParts(text: string): { name: string; argument: string } | null { + if (!text.startsWith('/')) { + return null + } + const match = /^\/([^\s]+)(?:\s+(.*))?$/.exec(text.trimEnd()) + return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null +} + +export function isMobileStructuredComposerCommand(text: string): boolean { + const command = commandParts(text) + return Boolean( + command && MOBILE_STRUCTURED_SLASH_COMMANDS.some((entry) => entry.name === command.name) + ) +} + +function unavailable(name: string): MobileStructuredCommandOutcome { + return { + handled: true, + accepted: true, + error: `/${name} is not available in chat sessions.` + } +} + +export async function dispatchMobileStructuredComposerCommand( + text: string, + controller: MobileNativeChatSessionOptionsController +): Promise { + const command = commandParts(text) + if (!command || !isMobileStructuredComposerCommand(text)) { + return { handled: false, accepted: false, error: null } + } + if (command.name !== 'model' && command.name !== 'effort') { + return unavailable(command.name) + } + const descriptor = controller.snapshot.find((entry) => entry.id === command.name) + if (!descriptor || descriptor.kind.type !== 'select') { + return { + handled: true, + accepted: true, + error: `${command.name === 'model' ? 'Models' : 'Reasoning effort'} are unavailable for this chat session.` + } + } + if (!command.argument) { + const opened = await controller.invokeAction(command.name) + return { + handled: true, + accepted: opened, + error: opened ? null : `Could not open the ${command.name} picker.` + } + } + const normalized = command.argument.toLowerCase() + const choice = descriptor.kind.choices.find( + (entry) => entry.value.toLowerCase() === normalized || entry.label.toLowerCase() === normalized + ) + if (!choice) { + return { + handled: true, + accepted: false, + error: `${command.argument} is not an available ${command.name} for this chat session.` + } + } + const applied = await controller.setOption(command.name, choice.value) + return { + handled: true, + accepted: applied, + error: applied ? null : `Could not apply ${command.name} ${choice.label}.` + } +} diff --git a/mobile/src/session/mobile-structured-session-option-catalog.ts b/mobile/src/session/mobile-structured-session-option-catalog.ts new file mode 100644 index 00000000000..98a20fcd247 --- /dev/null +++ b/mobile/src/session/mobile-structured-session-option-catalog.ts @@ -0,0 +1,40 @@ +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from '../../../src/shared/agent-session-option-catalog' +import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' + +function effortOption(model: AgentSessionOptionsResult['models'][number]): CatalogOption | null { + if (model.efforts.length <= 1) { + return null + } + return { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: model.efforts, + defaultValue: model.defaultEffort ?? model.efforts[0]!.value + }, + apply: { midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } + } +} + +export function mobileStructuredOptionCatalog( + seed: AgentSessionOptionCatalog, + result: AgentSessionOptionsResult +): AgentSessionOptionCatalog { + const models: CatalogModel[] = result.models.map((model) => { + const effort = effortOption(model) + return { + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + ...(model.isDefault ? { isDefault: true } : {}), + options: effort ? [effort] : [] + } + }) + return { ...seed, models, defaultModelIsCliDefault: true } +} diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 09779756631..4ccd3f40843 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -38,6 +38,9 @@ export type MobileNativeChatSessionOptionsController = { invokeAction: (id: string) => Promise /** Track a slash command the user typed themselves (e.g. `/model sonnet`). */ recordCommand: (command: string) => void + /** Structured commands can ask the existing picker to open without forging a TUI action. */ + pickerRequest?: { id: string; token: number } | null + dismissPickerRequest?: (token: number) => void } type PendingOperation = { id: string; token: number } diff --git a/mobile/src/session/use-mobile-structured-session-entry.ts b/mobile/src/session/use-mobile-structured-session-entry.ts index 6eedd2fdff0..35bd7ab874f 100644 --- a/mobile/src/session/use-mobile-structured-session-entry.ts +++ b/mobile/src/session/use-mobile-structured-session-entry.ts @@ -50,6 +50,8 @@ export function useMobileStructuredSessionEntry(args: { submissions: session.submissions }) const sessionOptions = useMobileStructuredSessionOptions({ + client, + connected, sessionId, setOption: writes.setOption }) diff --git a/mobile/src/session/use-mobile-structured-session-options.test.ts b/mobile/src/session/use-mobile-structured-session-options.test.ts index 2d09125fd84..d941344d589 100644 --- a/mobile/src/session/use-mobile-structured-session-options.test.ts +++ b/mobile/src/session/use-mobile-structured-session-options.test.ts @@ -1,6 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' import { useMobileStructuredSessionOptions } from './use-mobile-structured-session-options' @@ -9,18 +10,64 @@ describe('useMobileStructuredSessionOptions', () => { let controller: MobileNativeChatSessionOptionsController | null = null let sessionId = 'mobile_1' const setOption = vi.fn<(key: string, value: string) => Promise>() + const sendRequest = vi.fn() + const client = { sendRequest } as RpcClient function Probe(): null { - controller = useMobileStructuredSessionOptions({ sessionId, setOption }) + controller = useMobileStructuredSessionOptions({ + client, + connected: true, + sessionId, + setOption + }) return null } - beforeEach(() => { + beforeEach(async () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true setOption.mockReset().mockResolvedValue(true) + sendRequest.mockReset().mockResolvedValue({ + id: 'options', + ok: true, + result: { + models: [ + { + id: 'account-only', + label: 'Account Only', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6 Sol Live', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-5.6-terra', + label: 'GPT-5.6 Terra Live', + isDefault: false, + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { model: 'account-only', effort: 'medium' } + }, + _meta: { runtimeId: 'runtime-1' } + }) sessionId = 'mobile_1' - act(() => { + await act(async () => { renderer = create(createElement(Probe)) + await Promise.resolve() }) }) @@ -30,6 +77,24 @@ describe('useMobileStructuredSessionOptions', () => { controller = null }) + it('uses only provider models and hydrates current model and effort', () => { + expect(sendRequest).toHaveBeenCalledWith('agentSession.options', { sessionId: 'mobile_1' }) + const model = controller!.snapshot.find((entry) => entry.id === 'model') + expect(model?.kind).toEqual({ + type: 'select', + currentValue: 'account-only', + choices: [ + { value: 'account-only', label: 'Account Only' }, + { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol Live' }, + { value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra Live' } + ] + }) + expect(controller!.snapshot.find((entry) => entry.id === 'effort')).toMatchObject({ + kind: { currentValue: 'medium' }, + valueSource: 'reported' + }) + }) + it('uses agentSession.setOption and tracks the applied model and effort', async () => { await act(async () => { await controller!.setOption('model', 'gpt-5.6-sol') @@ -48,6 +113,25 @@ describe('useMobileStructuredSessionOptions', () => { }) }) + it('opens the real model and effort pickers for structured actions', async () => { + await act(async () => { + expect(await controller!.invokeAction('model')).toBe(true) + }) + expect(controller!.pickerRequest?.id).toBe('model') + await act(async () => { + controller!.dismissPickerRequest?.(controller!.pickerRequest!.token) + expect(await controller!.invokeAction('effort')).toBe(true) + }) + expect(controller!.pickerRequest?.id).toBe('effort') + }) + + it('rejects values absent from the live provider catalog', async () => { + await act(async () => { + expect(await controller!.setOption('model', 'static-only-model')).toBe(false) + }) + expect(setOption).not.toHaveBeenCalled() + }) + it('does not claim a rejected option was applied', async () => { setOption.mockResolvedValue(false) await act(async () => { @@ -55,7 +139,8 @@ describe('useMobileStructuredSessionOptions', () => { }) expect(controller!.snapshot.find((entry) => entry.id === 'model')).toMatchObject({ - valueSource: 'unknown' + valueSource: 'reported', + kind: { currentValue: 'account-only' } }) }) @@ -68,7 +153,10 @@ describe('useMobileStructuredSessionOptions', () => { pending = controller!.setOption('model', 'gpt-5.6-sol') }) sessionId = 'mobile_2' - act(() => renderer!.update(createElement(Probe))) + await act(async () => { + renderer!.update(createElement(Probe)) + await Promise.resolve() + }) await act(async () => { resolve(true) oldResult = await pending @@ -77,27 +165,8 @@ describe('useMobileStructuredSessionOptions', () => { expect(oldResult).toBe(false) expect(controller!.pendingId).toBeNull() expect(controller!.snapshot.find((entry) => entry.id === 'model')).toMatchObject({ - valueSource: 'unknown' + valueSource: 'reported', + kind: { currentValue: 'account-only' } }) }) - - it('does not restore stale pending state when returning to a prior session', async () => { - const oldRequest = new Promise(() => {}) - setOption.mockReturnValueOnce(oldRequest).mockResolvedValueOnce(true) - act(() => { - void controller!.setOption('model', 'gpt-5.6-sol') - }) - sessionId = 'mobile_2' - act(() => renderer!.update(createElement(Probe))) - sessionId = 'mobile_1' - act(() => renderer!.update(createElement(Probe))) - - let applied!: boolean - await act(async () => { - applied = await controller!.setOption('effort', 'high') - }) - - expect(applied).toBe(true) - expect(setOption).toHaveBeenNthCalledWith(2, 'effort', 'high') - }) }) diff --git a/mobile/src/session/use-mobile-structured-session-options.ts b/mobile/src/session/use-mobile-structured-session-options.ts index f85c4ed3a31..edfd157d6df 100644 --- a/mobile/src/session/use-mobile-structured-session-options.ts +++ b/mobile/src/session/use-mobile-structured-session-options.ts @@ -6,19 +6,27 @@ import { } from '../../../src/shared/native-chat-session-option-snapshot' import { createNativeChatSessionOptionRecord, + applyNativeChatReportedSessionOptions, setTrackedSessionOption } from '../../../src/shared/native-chat-session-option-state' import type { SessionOptionValue } from '../../../src/shared/native-chat-session-options' +import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredOptionCatalog } from './mobile-structured-session-option-catalog' import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' const CODEX_CATALOG = getAgentSessionOptionCatalog('codex') export function useMobileStructuredSessionOptions(args: { + client: RpcClient | null + connected: boolean sessionId: string | null setOption: (key: string, value: string) => Promise }): MobileNativeChatSessionOptionsController { - const { sessionId, setOption: dispatchOption } = args + const { client, connected, sessionId, setOption: dispatchOption } = args const [version, setVersion] = useState(0) + const [catalog, setCatalog] = useState(null) + const [pickerRequest, setPickerRequest] = useState<{ id: string; token: number } | null>(null) const [pending, setPending] = useState<{ sessionId: string id: string @@ -31,23 +39,60 @@ export function useMobileStructuredSessionOptions(args: { }, [record]) const pendingId = pending?.record === record ? pending.id : null + useEffect(() => { + setCatalog(null) + setPickerRequest(null) + if (!client || !connected || !sessionId || !CODEX_CATALOG) { + return + } + let stale = false + void client + .sendRequest('agentSession.options', { sessionId }) + .then((response) => { + if (stale || !response.ok) { + return + } + const result = response.result as AgentSessionOptionsResult + const live = mobileStructuredOptionCatalog(CODEX_CATALOG, result) + applyNativeChatReportedSessionOptions(record, { + model: result.current.model, + ...(result.current.effort ? { effort: result.current.effort } : {}) + }) + setCatalog(live) + setVersion((current) => current + 1) + }) + .catch(() => {}) + return () => { + stale = true + } + }, [client, connected, record, sessionId]) + const snapshot = useMemo(() => { void version - if (!CODEX_CATALOG || !sessionId) { + if (!catalog || !sessionId) { return [] } return buildNativeChatSessionOptionSnapshot({ - catalog: CODEX_CATALOG, - models: CODEX_CATALOG.models, + catalog, + models: catalog.models, record, mode: 'live', modelLabel: 'Model' }) - }, [record, sessionId, version]) + }, [catalog, record, sessionId, version]) const setOption = useCallback( async (id: string, value: SessionOptionValue): Promise => { - if (!CODEX_CATALOG || !sessionId || typeof value !== 'string' || pendingId !== null) { + const descriptor = snapshot.find((entry) => entry.id === id) + if ( + !catalog || + !sessionId || + typeof value !== 'string' || + pendingId !== null || + !descriptor || + descriptor.kind.type !== 'select' || + !descriptor.kind.choices.some((choice) => choice.value === value) + ) { return false } const targetSessionId = sessionId @@ -59,8 +104,8 @@ export function useMobileStructuredSessionOptions(args: { return false } const effectiveModel = resolveEffectiveNativeChatModelId( - CODEX_CATALOG, - CODEX_CATALOG.models, + catalog, + catalog.models, targetRecord ) setTrackedSessionOption(targetRecord, id, value, 'dispatched', effectiveModel) @@ -72,7 +117,18 @@ export function useMobileStructuredSessionOptions(args: { ) } }, - [dispatchOption, pendingId, record, sessionId] + [catalog, dispatchOption, pendingId, record, sessionId, snapshot] + ) + + const invokeAction = useCallback( + async (id: string): Promise => { + if (!snapshot.some((descriptor) => descriptor.id === id)) { + return false + } + setPickerRequest({ id, token: Date.now() }) + return true + }, + [snapshot] ) return useMemo( @@ -80,9 +136,12 @@ export function useMobileStructuredSessionOptions(args: { snapshot, pendingId, setOption, - invokeAction: async () => false, - recordCommand: () => {} + invokeAction, + recordCommand: () => {}, + pickerRequest, + dismissPickerRequest: (token: number) => + setPickerRequest((current) => (current?.token === token ? null : current)) }), - [pendingId, setOption, snapshot] + [invokeAction, pendingId, pickerRequest, setOption, snapshot] ) } diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index d15bb08be77..177f69db260 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -84,9 +84,15 @@ function fakeCodex(routes: Record = {}): { connections.push(connection) return connection }) as typeof openCodexAppServerConnection - routes['thread/start'] ??= () => ({ thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' } }) + routes['thread/start'] ??= () => ({ + thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' }, + model: 'gpt-live', + reasoningEffort: 'medium' + }) routes['thread/resume'] ??= (params) => ({ - thread: { id: (params as { threadId: string }).threadId } + thread: { id: (params as { threadId: string }).threadId }, + model: 'gpt-live', + reasoningEffort: 'medium' }) return { connections, openConnection, routes } } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 1db745af24c..6a2d207803b 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -26,6 +26,10 @@ import { openCodexThread } from './codex-structured-thread-open' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { closeCodexPublishedSession } from './codex-structured-session-close' +import { + readLiveCodexSessionOptions, + reportedCodexThreadOptions +} from './codex-structured-session-options' import { cancelCodexAcquisitionAttempt, CodexAcquisitionRegistry, @@ -41,8 +45,6 @@ export type { CodexStructuredSessionEvent } from './codex-structured-session-state' -// Owns one app-server child per structured Codex session; durable state stays on the wire. - export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter { private readonly sessions = new Map() private readonly acquisitions = new CodexAcquisitionRegistry() @@ -75,7 +77,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap try { await cancelCodexAcquisitionAttempt(previousAttempt) this.acquisitions.assertCurrent(sessionId, attempt) - await this.closePublishedSession(sessionId) + await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent) this.acquisitions.assertCurrent(sessionId, attempt) const launch = await this.deps.resolveLaunch({ identity: input.identity }) this.acquisitions.assertCurrent(sessionId, attempt) @@ -132,6 +134,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap historyPath: opened.historyPath, prompts: acquisition.prompts, options: new Map(), + reportedOptions: reportedCodexThreadOptions(opened), turnIdWaiters: [], translator }) @@ -226,12 +229,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap }) } - /** Lets the translation module address a live prompt by the journal item it - * became; until then the prompt key addresses it directly. */ - bindPromptItemId(sessionId: string, journalItemId: string, promptKey: string): void { - const session = this.sessions.get(sessionId) - session?.prompts.bindJournalItemId(journalItemId, session.threadId, promptKey) - } + bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void => + this.sessions + .get(sessionId) + ?.prompts.bindJournalItemId(journalItemId, this.session(sessionId).threadId, promptKey) async dispatch(input: { sessionId: string @@ -291,9 +292,12 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap session.options.set(input.key, input.value) } - async historyFilePath(input: { identity: AgentSessionJournalIdentity }): Promise { - return this.sessions.get(input.identity.sessionId)?.historyPath ?? null - } + readOptions = (input: { sessionId: string; fence: number }) => + readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + + historyFilePath = async (input: { + identity: AgentSessionJournalIdentity + }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null /** Reaps one session's child. The proven handle chain is already durable, so * a graceful close loses nothing. */ @@ -304,15 +308,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap await attempt.window.connection?.close() await attempt.finished } - await this.closePublishedSession(sessionId) - } - - private async closePublishedSession(sessionId: string): Promise { - await closeCodexPublishedSession({ - sessions: this.sessions, - sessionId, - onEvent: this.deps.onEvent - }) + await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent) } async closeAll(): Promise { @@ -323,9 +319,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } } - releaseAcquisition(input: { sessionId: string }): Promise { - return this.closeSession(input.sessionId) - } + releaseAcquisition = (input: { sessionId: string }): Promise => + this.closeSession(input.sessionId) private session(sessionId: string): CodexSession { const session = this.sessions.get(sessionId) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index dc3e401bf37..422074159c2 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,23 +1,23 @@ import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' -export async function closeCodexPublishedSession(input: { - sessions: Map - sessionId: string +export async function closeCodexPublishedSession( + sessions: Map, + sessionId: string, onEvent?: (event: CodexStructuredSessionEvent) => void -}): Promise { - const session = input.sessions.get(input.sessionId) +): Promise { + const session = sessions.get(sessionId) if (!session) { return } - input.sessions.delete(input.sessionId) + sessions.delete(sessionId) session.prompts.clear() const event: CodexStructuredSessionEvent = { type: 'ended', - sessionId: input.sessionId, + sessionId, reason: 'codex session closed' } session.translator?.handle(event) - input.onEvent?.(event) + onEvent?.(event) session.translator?.flush() session.translator?.dispose() await session.connection.close() diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts new file mode 100644 index 00000000000..577e42143ee --- /dev/null +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { + readCodexStructuredSessionOptions, + reportedCodexThreadOptions +} from './codex-structured-session-options' + +describe('structured Codex session options', () => { + it('hydrates paged provider models and their supported efforts', async () => { + const request = vi.fn(async (_method: string, params?: Record) => + params?.cursor + ? { + data: [ + { + model: 'gpt-second', + displayName: 'GPT Second', + description: 'Fast', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Quick reasoning' } + ], + defaultReasoningEffort: 'low', + isDefault: false + } + ], + nextCursor: null + } + : { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: 'page-2' + } + ) + + await expect( + readCodexStructuredSessionOptions({ + connection: { request } as never, + current: { model: 'gpt-live', effort: 'medium' } + }) + ).resolves.toEqual({ + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium', description: 'Balanced' }, + { value: 'high', label: 'High', description: 'Deep reasoning' } + ] + }, + { + id: 'gpt-second', + label: 'GPT Second', + description: 'Fast', + isDefault: false, + defaultEffort: 'low', + efforts: [{ value: 'low', label: 'Low', description: 'Quick reasoning' }] + } + ], + current: { model: 'gpt-live', effort: 'medium' } + }) + expect(request).toHaveBeenNthCalledWith( + 2, + 'model/list', + { limit: 100, includeHidden: false, cursor: 'page-2' }, + { timeoutMs: undefined } + ) + }) + + it('hydrates current values from thread start or resume', () => { + expect( + reportedCodexThreadOptions({ + threadId: 'thread-1', + historyPath: null, + model: 'gpt-live', + effort: 'high' + }) + ).toEqual({ model: 'gpt-live', effort: 'high' }) + }) +}) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts new file mode 100644 index 00000000000..1fc05be9bc3 --- /dev/null +++ b/src/main/codex/codex-structured-session-options.ts @@ -0,0 +1,135 @@ +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import type { CodexOpenedThread } from './codex-structured-thread-open' +import type { CodexSession } from './codex-structured-session-state' + +const MODEL_PAGE_LIMIT = 100 +const MAX_MODEL_PAGES = 20 + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function effortLabel(value: string): string { + return value === 'xhigh' + ? 'Extra high' + : value === 'minimal' + ? 'Minimal' + : `${value.charAt(0).toUpperCase()}${value.slice(1)}` +} + +function effortChoice(value: unknown): AgentSessionOptionChoice | null { + const row = record(value) + const effort = text(row?.reasoningEffort) + if (!effort) { + return null + } + const description = text(row?.description) + return { + value: effort, + label: effortLabel(effort), + ...(description ? { description } : {}) + } +} + +function modelOption(value: unknown): AgentSessionModelOption | null { + const row = record(value) + if (!row) { + return null + } + const id = text(row.model) ?? text(row.id) + const label = text(row.displayName) ?? id + if (!id || !label || row.hidden === true) { + return null + } + const description = text(row.description) + const defaultEffort = text(row.defaultReasoningEffort) + const efforts = Array.isArray(row.supportedReasoningEfforts) + ? row.supportedReasoningEfforts + .map(effortChoice) + .filter((choice): choice is AgentSessionOptionChoice => choice !== null) + : [] + return { + id, + label, + ...(description ? { description } : {}), + isDefault: row.isDefault === true, + ...(defaultEffort ? { defaultEffort } : {}), + efforts + } +} + +export async function readCodexStructuredSessionOptions(input: { + connection: Pick + current: { model?: string; effort?: string } + timeoutMs?: number +}): Promise { + const models: AgentSessionModelOption[] = [] + let cursor: string | null = null + for (let page = 0; page < MAX_MODEL_PAGES; page += 1) { + const response = record( + await input.connection.request( + 'model/list', + { limit: MODEL_PAGE_LIMIT, includeHidden: false, ...(cursor ? { cursor } : {}) }, + { timeoutMs: input.timeoutMs } + ) + ) + const rows = Array.isArray(response?.data) ? response.data : [] + for (const row of rows) { + const parsed = modelOption(row) + if (parsed && !models.some((model) => model.id === parsed.id)) { + models.push(parsed) + } + } + cursor = text(response?.nextCursor) + if (!cursor) { + break + } + } + if (input.current.model && !models.some((model) => model.id === input.current.model)) { + models.push({ + id: input.current.model, + label: input.current.model, + isDefault: false, + efforts: [] + }) + } + const model = input.current.model ?? models.find((entry) => entry.isDefault)?.id ?? models[0]?.id + if (!model) { + throw new Error('codex app-server returned no available models') + } + return { + models, + current: { model, ...(input.current.effort ? { effort: input.current.effort } : {}) } + } +} + +export function reportedCodexThreadOptions( + opened: CodexOpenedThread +): CodexSession['reportedOptions'] { + return { + ...(opened.model ? { model: opened.model } : {}), + ...(opened.effort ? { effort: opened.effort } : {}) + } +} + +export function readLiveCodexSessionOptions( + session: CodexSession, + timeoutMs: number | undefined +): Promise { + const model = session.options.get('model') ?? session.reportedOptions.model + const effort = session.options.get('effort') ?? session.reportedOptions.effort + return readCodexStructuredSessionOptions({ + connection: session.connection, + current: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, + timeoutMs + }) +} diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 27a954976e7..654fb8faa58 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -45,6 +45,7 @@ export type CodexSession = { historyPath: string | null prompts: CodexAcquisitionWindow['prompts'] options: Map + reportedOptions: { model?: string; effort?: string } turnIdWaiters: ((turnId: string) => void)[] translator: CodexJournalTranslator | null } diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index cb059bf3f46..77e50da7779 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -12,6 +12,12 @@ export type CodexOpenedThread = { threadId: string /** Rollout file Codex named, when it named one. */ historyPath: string | null + model?: string + effort?: string +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null } export async function openCodexThread( @@ -33,5 +39,13 @@ export async function openCodexThread( if (launch.resumeThreadId && threadId !== launch.resumeThreadId) { throw new Error(`codex app-server resumed ${threadId} instead of ${launch.resumeThreadId}`) } - return { threadId, historyPath: readCodexThreadPath(opened) } + const result = opened as Record + const model = nonEmptyString(result.model) + const effort = nonEmptyString(result.reasoningEffort) + return { + threadId, + historyPath: readCodexThreadPath(opened), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}) + } } diff --git a/src/main/index.ts b/src/main/index.ts index 214fe950cd3..a7436b003c8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2453,6 +2453,11 @@ void app.whenReady().then(async () => { codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), + prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { + launchAgent: 'codex', + workspacePath + }), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, orchestrationEnvironmentTransport diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6c72b23b277..eea191011c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -16,6 +16,7 @@ import type { AgentSessionExecutionLocation, AgentSessionProcessIdentity } from '../../../shared/agent-session-record' +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' /** What a reservation turns into once something is actually running under it: @@ -73,6 +74,7 @@ export type StructuredAgentSessionAdapter = { fence: number }): Promise setOption(input: { sessionId: string; key: string; value: string; fence: number }): Promise + readOptions?(input: { sessionId: string; fence: number }): Promise /** Transcript path for journal recovery. Omit to let the existing session-file * resolver discover it from the provider session id. */ historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index ade4235bfd6..b3cd6d78f93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -25,6 +25,7 @@ import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, AgentSessionOptionResult, + AgentSessionOptionsResult, AgentSessionPromptResult, AgentSessionSendResult, AgentSessionWireRefusal @@ -57,7 +58,7 @@ import { type AgentSessionSubscriberEmit } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' -import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' export type StructuredAgentSessionCaller = { /** Stable per-client identity; scopes the operation ledger and records who @@ -124,29 +125,23 @@ export class StructuredAgentSessionHost { } async restoreReadableSessions(): Promise { - await Promise.all( - this.deps.store - .listRecords() - .filter((record) => record.provider === 'codex') - .map((record) => - this.serialize(record.sessionId, async () => { - if (this.sessions.has(record.sessionId)) { - return - } - const restored = await restoreStructuredAgentSessionRead( - this.deps.store, - this.deps.journalRoot, - record.sessionId - ) - if (restored) { - this.sessions.set(record.sessionId, restored) - } - }) - ) - ) + await restoreStructuredAgentSessionsOnRestart({ + store: this.deps.store, + journalRoot: this.deps.journalRoot, + records: this.deps.store.listRecords().filter((record) => record.provider === 'codex'), + reconcile: this.reconcileLeases, + operationId: () => + `${Math.trunc(this.now()).toString().padStart(13, '0')}-${randomUUID().replaceAll('-', '')}`, + resume: (params) => + this.attach({ callerKey: 'trusted-local:host-restart' }, params).then( + (result) => result.ok + ), + serialize: (sessionId, task) => this.serialize(sessionId, task), + hasSession: (sessionId) => this.sessions.has(sessionId), + onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored) + }) } - /** Per-session single file. A rejected task must not poison the chain. */ private serialize(sessionId: string, task: () => Promise): Promise { return this.tasks.serialize(sessionId, task) } @@ -306,6 +301,16 @@ export class StructuredAgentSessionHost { return this.mutate(caller, params.envelope, setOptionPlan(params)) } + readOptions(sessionId: string): Promise { + return this.serialize(sessionId, async () => { + const session = this.requireSession(sessionId) + if (!this.deps.adapter.readOptions) { + throw new Error('structured_agent_session_options_unsupported') + } + return this.deps.adapter.readOptions({ sessionId, fence: session.fence }) + }) + } + private mutate( caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, @@ -350,9 +355,7 @@ export class StructuredAgentSessionHost { return this.subscribers.open({ ...input, journal: session.journal, fence }) } - unsubscribe(sessionId: string, id: string): void { - this.subscribers.close(sessionId, id) - } + unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) private requireSession(sessionId: string): SessionState { const session = this.sessions.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index ee8c1a90891..82d3116c9ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -7,9 +7,11 @@ import { type AgentSessionJournal } from '../agent-session-journal/journal-store' import { + attachFingerprintFields, journalIdentityFor, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' export type RestoredStructuredAgentSessionRead = { journal: AgentSessionJournal @@ -26,7 +28,10 @@ export async function restoreStructuredAgentSessionRead( if (!record || record.provider !== 'codex') { return null } - const params = attachParamsForReadableRestore(record) + const params = attachParamsForRecord(record, { + clientOperationId: `read-restore:${record.sessionId}`, + expectedRuntimeFence: record.lease.runtimeFence + }) const journalDir = journalDirectoryFor(journalRoot, { workspaceId: record.location.workspaceId, sessionId @@ -42,12 +47,15 @@ export async function restoreStructuredAgentSessionRead( return { journal, params, fence: record.lease.runtimeFence } } -function attachParamsForReadableRestore(record: AgentSessionRecord): AgentSessionAttachParams { - return { +export function attachParamsForRecord( + record: AgentSessionRecord, + input: { clientOperationId: string; expectedRuntimeFence: number } +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { envelope: { sessionId: record.sessionId, - clientOperationId: `read-restore:${record.sessionId}`, - expectedRuntimeFence: record.lease.runtimeFence, + clientOperationId: input.clientOperationId, + expectedRuntimeFence: input.expectedRuntimeFence, payloadFingerprint: '' }, location: record.location, @@ -56,4 +64,15 @@ function attachParamsForReadableRestore(record: AgentSessionRecord): AgentSessio accountHome: record.accountHome, runtimeKind: record.lease.runtimeKind } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: record.sessionId, + fields: attachFingerprintFields(params) + }) + } + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts new file mode 100644 index 00000000000..9543fea4441 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -0,0 +1,53 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { + attachParamsForRecord, + restoreStructuredAgentSessionRead, + type RestoredStructuredAgentSessionRead +} from './structured-agent-session-read-restore' + +export async function restoreStructuredAgentSessionsOnRestart(input: { + store: AgentSessionRecordStore + journalRoot: string + records: AgentSessionRecord[] + reconcile: (sessionId: string) => Promise + operationId: () => string + resume: (params: AgentSessionAttachParams) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + hasSession: (sessionId: string) => boolean + onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void +}): Promise { + await Promise.all( + input.records.map(async ({ sessionId }) => { + const unreconciled = await input.reconcile(sessionId) + const current = input.store.getRecord(sessionId) + if ( + !unreconciled && + current?.lease.claimStatus === 'released' && + (await input.resume( + attachParamsForRecord(current, { + clientOperationId: input.operationId(), + expectedRuntimeFence: current.lease.runtimeFence + }) + )) + ) { + return + } + await input.serialize(sessionId, async () => { + if (input.hasSession(sessionId)) { + return + } + const restored = await restoreStructuredAgentSessionRead( + input.store, + input.journalRoot, + sessionId + ) + if (restored) { + input.onReadable(sessionId, restored) + } + }) + }) + ) +} diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts new file mode 100644 index 00000000000..083c677e39f --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +describe('structured agent-session create intent', () => { + it('pins the selected Codex launch home after normal launch preparation', async () => { + const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { codex: { CODEX_HOME: '/configured/home' } } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'codex' + }) + + expect(prepareCodexStructuredLaunch).toHaveBeenCalledWith({ + workspacePath: '/repos/workspace-1', + launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) + }) + expect(intent.accountHome).toEqual({ + variable: 'CODEX_HOME', + path: '/accounts/selected/home' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ff7962f6977..246c4f33b20 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3337,6 +3337,9 @@ export class OrcaRuntimeService { private readonly prepareAiVaultSessionResumeFn: | ((args: AiVaultPrepareSessionResumeArgs) => Promise) | null + private readonly prepareCodexStructuredLaunchFn: + | ((input: { workspacePath: string; launchEnv: NodeJS.ProcessEnv }) => string | null) + | null private readonly agentSessionClaimSigner: AgentSessionClaimSigner private readonly agentSessionCreateOperations = new Map() private readonly orchestrationCompatibilitySshAttachments = new Map< @@ -3414,6 +3417,10 @@ export class OrcaRuntimeService { prepareAiVaultSessionResume?: ( args: AiVaultPrepareSessionResumeArgs ) => Promise + prepareCodexStructuredLaunch?: (input: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus agentSessionClaimSigner?: AgentSessionClaimSigner @@ -3464,6 +3471,7 @@ export class OrcaRuntimeService { this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null + this.prepareCodexStructuredLaunchFn = deps?.prepareCodexStructuredLaunch ?? null this.agentSessionClaimSigner = deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId) this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null @@ -9245,8 +9253,11 @@ export class OrcaRuntimeService { throw new Error('structured_agent_session_unsupported') } const settings = this.requireStore().getSettings() - const configuredHome = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv).CODEX_HOME + const launchEnv = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) + const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + const preparedHome = this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) + const configuredHome = launchEnv.CODEX_HOME return { envelope: { sessionId: input.envelope.sessionId, @@ -9259,7 +9270,12 @@ export class OrcaRuntimeService { agent: 'codex', accountHome: { variable: 'CODEX_HOME', - path: configuredHome?.trim() || getSystemCodexHomePath() + path: + preparedHome?.trim() || + (this.prepareCodexStructuredLaunchFn + ? getSystemCodexHomePath() + : configuredHome?.trim()) || + getSystemCodexHomePath() }, runtimeKind: 'native' } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index d6ec84051d7..4288c5253c6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -170,6 +170,8 @@ export const SetOptionParams = z }) .strict() +export const OptionsParams = z.object({ sessionId: SessionId }).strict() + export const HistoryParams = z .object({ sessionId: SessionId, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 0f4b1653f8b..5d82b9c4969 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -86,6 +86,10 @@ function hostStub(): StructuredAgentSessionHost { cancel: vi.fn(async () => ({ ok: true, replayed: false })), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + readOptions: vi.fn(async () => ({ + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + current: { model: 'gpt-live' } + })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), subscribe: vi.fn(() => () => undefined), unsubscribe: vi.fn() @@ -170,7 +174,7 @@ describe('capability gating', () => { for (const method of STRUCTURED_AGENT_SESSION_METHODS) { expect(names).toContain(method.name) } - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(11) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(12) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 660f4fa0d30..1f667db1953 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -23,6 +23,7 @@ import { CreateParams, CreateSupportParams, HistoryParams, + OptionsParams, RespondParams, SendParams, SetOptionParams, @@ -184,6 +185,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: SetOptionParams, handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) }), + defineMethod({ + name: 'agentSession.options', + params: OptionsParams, + handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId) + }), defineMethod({ name: 'agentSession.history', params: HistoryParams, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8cbe97281ab..f44512d68cb 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -189,6 +189,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.createSupport', 'agentSession.cancel', 'agentSession.history', + 'agentSession.options', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', 'agentSession.send', diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 7c876ec6f13..2f29691e8a4 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -178,3 +178,28 @@ export type AgentSessionOptionResult = { key: string value: string } + +export type AgentSessionOptionChoice = { + value: string + label: string + description?: string +} + +export type AgentSessionModelOption = { + id: string + label: string + description?: string + isDefault: boolean + defaultEffort?: string + efforts: AgentSessionOptionChoice[] +} + +/** Provider-reported choices and effective next-turn values. Additive read-only + * surface so older hosts can reject it without changing structured v1 writes. */ +export type AgentSessionOptionsResult = { + models: AgentSessionModelOption[] + current: { + model: string + effort?: string + } +} diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index f1301bfc144..90aa844efa2 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -46,6 +46,7 @@ const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' }, { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, { method: 'agentSession.setOption', hostMethod: 'setOption' }, + { method: 'agentSession.options', hostMethod: 'readOptions' }, { method: 'agentSession.history', hostMethod: 'history' }, { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, // Teardown runs through the runtime's subscription registry rather than the @@ -233,6 +234,7 @@ describe('cross-version structured agent sessions', () => { cancel: vi.fn(async () => ({ ok: true, replayed: false })), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), subscribe: vi.fn(() => () => undefined), unsubscribe: vi.fn() @@ -292,7 +294,7 @@ describe('cross-version structured agent sessions', () => { it('finds no structured method registered on the old build', () => { expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( - 11 + 12 ) }) @@ -356,24 +358,24 @@ describe('cross-version structured agent sessions', () => { /** Reopens the store from disk and installs a fresh host over the same journal * root — what a process restart actually leaves behind. */ - async function bootHost(generation: string): Promise { + async function bootHost(generation: string): Promise { store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - setStructuredAgentSessionHost( - new StructuredAgentSessionHost({ - store, - adapter: adapter(), - journalRoot: root, - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${generation}`, - // The provider died with the host that spawned it, which is what makes - // the restarted host the legitimate next writer. - probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW - }) - ) + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation}`, + // The provider died with the host that spawned it, which is what makes + // the restarted host the legitimate next writer. + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + setStructuredAgentSessionHost(host) + return host } type HostAnswer = { @@ -441,14 +443,11 @@ describe('cross-version structured agent sessions', () => { expect(first.ok).toBe(true) const held = first.cursor - await bootHost('b') - // A restart is a new writer generation; the old fence must not survive it. - const reattached = await reattach(created.fence) - expect(reattached.fence).toBeGreaterThan(created.fence) - const second = await answer( - 'agentSession.send', - sendParams('after restart', reattached.fence) - ) + const restarted = await bootHost('b') + await restarted.restoreReadableSessions() + const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(resumedFence).toBeGreaterThan(created.fence) + const second = await answer('agentSession.send', sendParams('after restart', resumedFence)) expect(second.ok).toBe(true) const events = (