mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix: restore structured chat parity
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user