fix: restore structured chat parity

This commit is contained in:
Brennan Benson
2026-08-10 01:37:59 -07:00
parent 5435cf7257
commit 9202192f10
31 changed files with 1062 additions and 136 deletions
@@ -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<boolean>
/** 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') {
@@ -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
@@ -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<void> {
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.'])
})
})
@@ -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<string | null>(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
</Pressable>
</View>
) : null}
{props.writeError ? (
{commandError || props.writeError ? (
<View style={styles.writeError} accessibilityRole="alert">
<Text style={styles.writeErrorText}>{props.writeError}</Text>
<Text style={styles.writeErrorText}>{commandError ?? props.writeError}</Text>
</View>
) : null}
<MobileNativeChatComposer
value={composerText}
onChangeText={setComposerText}
onSend={async (text) => {
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}
@@ -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 })
})
})
@@ -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<MobileStructuredCommandOutcome> {
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}.`
}
}
@@ -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 }
}
@@ -38,6 +38,9 @@ export type MobileNativeChatSessionOptionsController = {
invokeAction: (id: string) => Promise<boolean>
/** 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 }
@@ -50,6 +50,8 @@ export function useMobileStructuredSessionEntry(args: {
submissions: session.submissions
})
const sessionOptions = useMobileStructuredSessionOptions({
client,
connected,
sessionId,
setOption: writes.setOption
})
@@ -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<boolean>>()
const sendRequest = vi.fn<RpcClient['sendRequest']>()
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<boolean>(() => {})
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')
})
})
@@ -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<boolean>
}): MobileNativeChatSessionOptionsController {
const { sessionId, setOption: dispatchOption } = args
const { client, connected, sessionId, setOption: dispatchOption } = args
const [version, setVersion] = useState(0)
const [catalog, setCatalog] = useState<typeof CODEX_CATALOG>(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<boolean> => {
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<boolean> => {
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]
)
}
@@ -84,9 +84,15 @@ function fakeCodex(routes: Record<string, Route> = {}): {
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 }
}
@@ -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<string, CodexSession>()
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<string | null> {
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<string | null> => 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<void> {
await closeCodexPublishedSession({
sessions: this.sessions,
sessionId,
onEvent: this.deps.onEvent
})
await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent)
}
async closeAll(): Promise<void> {
@@ -323,9 +319,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
}
releaseAcquisition(input: { sessionId: string }): Promise<void> {
return this.closeSession(input.sessionId)
}
releaseAcquisition = (input: { sessionId: string }): Promise<void> =>
this.closeSession(input.sessionId)
private session(sessionId: string): CodexSession {
const session = this.sessions.get(sessionId)
@@ -1,23 +1,23 @@
import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state'
export async function closeCodexPublishedSession(input: {
sessions: Map<string, CodexSession>
sessionId: string
export async function closeCodexPublishedSession(
sessions: Map<string, CodexSession>,
sessionId: string,
onEvent?: (event: CodexStructuredSessionEvent) => void
}): Promise<void> {
const session = input.sessions.get(input.sessionId)
): Promise<void> {
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()
@@ -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<string, unknown>) =>
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' })
})
})
@@ -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<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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<CodexAppServerConnection, 'request'>
current: { model?: string; effort?: string }
timeoutMs?: number
}): Promise<AgentSessionOptionsResult> {
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<AgentSessionOptionsResult> {
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
})
}
@@ -45,6 +45,7 @@ export type CodexSession = {
historyPath: string | null
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
reportedOptions: { model?: string; effort?: string }
turnIdWaiters: ((turnId: string) => void)[]
translator: CodexJournalTranslator | null
}
+15 -1
View File
@@ -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<string, unknown>
const model = nonEmptyString(result.model)
const effort = nonEmptyString(result.reasoningEffort)
return {
threadId,
historyPath: readCodexThreadPath(opened),
...(model ? { model } : {}),
...(effort ? { effort } : {})
}
}
+5
View File
@@ -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
@@ -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<void>
setOption(input: { sessionId: string; key: string; value: string; fence: number }): Promise<void>
readOptions?(input: { sessionId: string; fence: number }): Promise<AgentSessionOptionsResult>
/** 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<string | null>
@@ -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<void> {
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<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
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<AgentSessionOptionsResult> {
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<TValue>(
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)
@@ -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)
})
}
}
}
@@ -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<AgentSessionWireRefusal | null>
operationId: () => string
resume: (params: AgentSessionAttachParams) => Promise<boolean>
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
hasSession: (sessionId: string) => boolean
onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void
}): Promise<void> {
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)
}
})
})
)
}
@@ -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'
})
})
})
+18 -2
View File
@@ -3337,6 +3337,9 @@ export class OrcaRuntimeService {
private readonly prepareAiVaultSessionResumeFn:
| ((args: AiVaultPrepareSessionResumeArgs) => Promise<AiVaultPrepareSessionResumeResult>)
| null
private readonly prepareCodexStructuredLaunchFn:
| ((input: { workspacePath: string; launchEnv: NodeJS.ProcessEnv }) => string | null)
| null
private readonly agentSessionClaimSigner: AgentSessionClaimSigner
private readonly agentSessionCreateOperations = new Map<string, AgentSessionCreateOperation>()
private readonly orchestrationCompatibilitySshAttachments = new Map<
@@ -3414,6 +3417,10 @@ export class OrcaRuntimeService {
prepareAiVaultSessionResume?: (
args: AiVaultPrepareSessionResumeArgs
) => Promise<AiVaultPrepareSessionResumeResult>
prepareCodexStructuredLaunch?: (input: {
workspacePath: string
launchEnv: NodeJS.ProcessEnv
}) => string | null
buildAgentHookPtyEnv?: () => Record<string, string>
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'
}
@@ -170,6 +170,8 @@ export const SetOptionParams = z
})
.strict()
export const OptionsParams = z.object({ sessionId: SessionId }).strict()
export const HistoryParams = z
.object({
sessionId: SessionId,
@@ -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 () => {
@@ -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,
+1
View File
@@ -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',
+25
View File
@@ -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
}
}
@@ -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<void> {
async function bootHost(generation: string): Promise<StructuredAgentSessionHost> {
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 = (