Keep a chat's accepted model options in what it shows and sends (#31208)

* Keep a chat's accepted pick in what it shows and sends

A pick held while a new chat is created is committed into the chat's
active option record once accepted, through the same step a normal pick
takes, and older options reads are dropped. The host also publishes the
chat's saved effort, Fast and speed as optional status fields so another
device's pick moves the option stamp and re-reads options.

* Keep chat picks ordered and capture displayed send selection

* fix: keep chat choices in one controller with accepted input capture

* fix: pass captured Codex model input to the turn request

* Keep structured chat option pickers usable during turns

* Preserve logical selection ownership across runtime transitions

* Consolidate selection lifecycle contract coverage

* Consolidate mobile structured send imports

* Preserve legacy action outcomes and opening message identity

* Consolidate repeated selection test setup and assertions

* Fix option reset admission and preserve uncertain write notices

* fix(chat): complete option contract fixture types

* test(chat): place affinity integration in cross-layer suite
This commit is contained in:
Brennan Benson
2026-10-29 14:18:23 -07:00
committed by GitHub
parent be4b3ffad3
commit 2e38249a7d
118 changed files with 6963 additions and 1815 deletions
@@ -16,6 +16,7 @@ import {
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import {
AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY
} from '../../../src/shared/agent-session-create-capabilities'
@@ -47,6 +48,8 @@ export type StructuredAgentSessionHostSupport = {
// Hosts predating mobile access refuse this feed even if they implement it.
statusFeed: boolean
quietRepeatedStop: boolean
/** A send may carry the model and effort its turn runs on. */
sendSelection?: boolean
sendOptions: boolean
/** Older hosts refuse a send body carrying picked skill paths. */
sendSkills?: boolean
@@ -83,6 +86,7 @@ export function structuredAgentSessionHostSupport(
),
statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY),
sendSelection: capabilities.includes(AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY),
sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY),
sendSkills: capabilities.includes(AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY),
discoverWorkspaceSkills: capabilities.includes(SKILL_DISCOVER_WORKSPACE_CAPABILITY),
@@ -29,6 +29,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
skills?: readonly AgentJournalSkillRef[]
/** Sent only when the host advertises `agent-session.queued-messages.v1`. */
delivery?: 'queue-if-active'
/** Sent only when the host advertises `agent-session.send-selection.v1`. */
selection?: Readonly<Record<string, string>>
/** Sent only when the host advertises `agent-session.send-options.v1`. */
options?: Readonly<Record<string, string>>
deadline?: number
@@ -57,7 +59,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
fields: {
body,
...(input.delivery ? { delivery: input.delivery } : {}),
...(input.options ? { options: input.options } : {})
...(input.options ? { options: input.options } : {}),
...(input.selection !== undefined ? { selection: input.selection } : {})
},
clientOperationId: input.clientMessageId,
timeoutMs
@@ -1,3 +1,14 @@
import type { AgentSessionOptionsResult } from '../../../../../src/shared/agent-session-wire'
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
import {
OPTIONS,
BASE,
deferred,
accepted,
optionsClient,
recordingMutate,
mountOptions
} from '../../use-mobile-structured-agent-options.test-fixture'
import { createElement, useState, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -168,6 +179,86 @@ describe('MobileNativeChatSessionOptionPickers', () => {
renderer = null
})
it('renders actual settled permission/reset picks without busy state and locks ordinary pending model work', async () => {
const idle = deferred<AgentSessionOptionsResult>()
let reads = 0
const rpc = optionsClient(() =>
++reads === 1
? Promise.resolve({ ...OPTIONS, current: { ...OPTIONS.current, permissionMode: 'ask' } })
: idle.promise
)
const replies = Array.from({ length: 3 }, () => deferred<ReturnType<typeof accepted>>())
const { calls, mutate } = recordingMutate((index) => replies[index].promise)
const view = await mountOptions({ ...BASE, client: rpc.client, mutate, permissionOwn: true })
function ActualPickers() {
const options = view.current()
const { nativeChatSessionOptions } = useMobileNativeChatSessionOptionController({
client: rpc.client,
activeChatStructured: true,
activeSessionTabId: BASE.sessionId,
agent: 'codex',
dispatchCommand: async () => 'accepted',
hostId: 'phone-picker',
isTabChatView: () => true,
isWorking: false,
reportedModel: null,
structured: {
snapshot: options.optionSnapshot,
pendingId: options.pendingOptionId,
setOption: options.setStructuredOption,
invokeAction: options.invokeStructuredOption
},
toggleTabChatView: () => {},
worktreeId: 'workspace-1'
})
return createElement(MobileNativeChatSessionOptionPickers, {
...nativeChatSessionOptions!,
presentation: { page: 'model', refocus: false },
onDrawerOpen: () => {},
onNavigate: () => {}
})
}
try {
await act(async () => {
renderer = create(createElement(ActualPickers))
})
for (const [index, value] of ['full-access', 'own'].entries()) {
let pick!: Promise<boolean>
act(() => {
pick = view.current().setStructuredOption('permissionMode', value)
})
expect(calls[index]?.fields).toEqual({ key: 'permissionMode', value })
expect(view.current().pendingOptionId).toBeNull()
await act(async () => renderer!.update(createElement(ActualPickers)))
expect(findAllByHostType(renderer!.root, 'ActivityIndicator')).toHaveLength(0)
expect(rowByText('GPT Fast').props.accessibilityState?.disabled).toBe(false)
await act(async () =>
replies[index].resolve(
accepted({ key: 'permissionMode', value, options: { permissionMode: value } }, true)
)
)
await expect(pick).resolves.toBe(true)
expect(view.current().pendingOptionId).toBeNull()
await act(async () => renderer!.update(createElement(ActualPickers)))
expect(findAllByHostType(renderer!.root, 'ActivityIndicator')).toHaveLength(0)
expect(rowByText('GPT Fast').props.accessibilityState?.disabled).toBe(false)
}
act(() => {
void view.current().setStructuredOption('model', 'gpt-fast')
})
await act(async () => renderer!.update(createElement(ActualPickers)))
expect(rowByText('GPT Live').props.accessibilityState?.disabled).toBe(true)
expect(findAllByHostType(renderer!.root, 'ActivityIndicator')).toHaveLength(1)
expect(calls.map((call) => call.fields)).toEqual([
{ key: 'permissionMode', value: 'full-access' },
{ key: 'permissionMode', value: 'own' },
{ key: 'model', value: 'gpt-fast' }
])
} finally {
await view.unmount()
}
})
it('renders nothing without a model descriptor', () => {
mount([])
expect(renderer!.toJSON()).toBeNull()
@@ -59,7 +59,12 @@ async function mount(initial: Props) {
fence: props.fence,
mutate,
turnId: null,
hostSupport: { permissionModes: true, coherentModelCatalog: true, statusFeed: false }
hostSupport: {
sendOptions: false,
permissionModes: true,
coherentModelCatalog: true,
statusFeed: false
}
})
latest = options
frames.push(modelChoices(options.optionSnapshot))
@@ -0,0 +1,424 @@
import * as selectionOperations from '../../../src/shared/structured-agent-session-selection-operations'
import { createElement, StrictMode, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { expect, it, vi } from 'vitest'
import { OptionsParams } from '../../../src/shared/rpc-contract/structured-agent-session-params'
import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client'
import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc'
import { useMobileStructuredSelectionController } from './use-mobile-structured-selection-controller'
import {
settle,
accepted,
OPTIONS,
BASE,
deferred,
rpcSuccess,
optionsClient,
queuedReads,
recordingMutate,
mountOptions,
currentValueOf
} from './use-mobile-structured-agent-options.test-fixture'
function acceptedWrite({ fields }: { fields: Record<string, unknown> }) {
const { key, value } = fields
if (typeof key !== 'string' || typeof value !== 'string') {
throw new Error('expected a recorded option write')
}
return accepted({ key, value, options: { [key]: value } }, true)
}
it('keeps actual phone operations and correlated acceptance through a stable-client path migration', async () => {
const stale = deferred<AgentSessionOptionsResult>()
let oldReads = 0
const physical = optionsClient(() =>
++oldReads === 1 ? Promise.resolve(OPTIONS) : stale.promise
)
const currentReads: ReturnType<typeof deferred<AgentSessionOptionsResult>>[] = []
const next = optionsClient(() => {
const read = deferred<AgentSessionOptionsResult>()
currentReads.push(read)
return read.promise
})
const reply = deferred<ReturnType<typeof rpcSuccess>>()
const send = physical.client.sendRequest
physical.client.sendRequest = (method, params, options) =>
method === 'agentSession.setOption' ? reply.promise : send(method, params, options)
const client = createStableLogicalRpcClient(physical.client, 'lan')
const mutate: StructuredAgentSessionMutate = async <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
) => {
const result = await requestStructuredAgentSessionMutation<TValue>({
client,
method,
fingerprintMethod,
fields,
sessionId: BASE.sessionId,
expectedRuntimeFence: 1
})
return result.status === 'accepted'
? { status: 'accepted', value: result.value, sameFence: false }
: result.status === 'unknown'
? { status: 'unknown' }
: { status: 'rejected' }
}
const view = await mountOptions({ ...BASE, client, mutate, sendSelection: true })
let pick!: Promise<boolean>
act(() => {
pick = view.current().setStructuredOption('model', 'gpt-fast')
})
await view.rerender({ optionStamp: 'old-query' })
await act(async () => {
await client.migrateTo(next.client, 'relay')
})
await settle()
await act(async () => stale.resolve({ ...OPTIONS, conversationCommands: ['compact'] }))
expect(view.current().conversationCommands).toEqual([])
expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-fast')
expect(view.current().sendSelection()).toMatchObject({ selection: { model: 'gpt-fast' } })
await act(async () =>
reply.resolve(rpcSuccess({ ok: true, value: { options: { model: 'gpt-fast' } } }))
)
await expect(pick).resolves.toBe(true)
expect(next.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(1)
// The first current query began before acceptance; its trailing query captures the terminal ID.
await act(async () => currentReads[0].resolve(OPTIONS))
await settle()
await act(async () => currentReads.at(-1)!.resolve(OPTIONS))
await settle()
expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-live')
await view.unmount()
client.close()
})
it.each(['session', 'client'] as const)(
'ends actual %s scope and cleanup replay without reviving setup-time work',
async (boundary) => {
const firstClient = optionsClient(queuedReads(OPTIONS))
const replacement = optionsClient(queuedReads(OPTIONS))
let current!: selectionOperations.StructuredSessionSelectionController
const setups: {
controller: selectionOperations.StructuredSessionSelectionController
operation: selectionOperations.SelectionOperation
}[] = []
let cleanups = 0
function Scope({ sessionId, client }: { sessionId: string; client: RpcClient }) {
const { controller } = useMobileStructuredSelectionController({
agent: 'codex',
client,
sessionId,
onRevalidate: () => {}
})
current = controller
useEffect(() => {
setups.push({ controller, operation: controller.issue('model', 'gpt-live', null) })
return () => {
cleanups++
}
}, [controller])
return null
}
const element = (sessionId: string, client: RpcClient) =>
createElement(StrictMode, null, createElement(Scope, { sessionId, client }))
let root!: ReactTestRenderer
await act(async () => {
root = create(element('scope-A', firstClient.client))
})
expect(setups).toHaveLength(2)
expect(cleanups).toBe(1)
const old = setups[0]
await expect(old.operation.result).resolves.toEqual({ kind: 'superseded' })
act(() =>
old.controller.settle(old.operation, { kind: 'accepted', options: { model: 'gpt-live' } })
)
expect(firstClient.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(0)
const live = setups[1]
act(() =>
live.controller.settle(live.operation, { kind: 'accepted', options: { model: 'gpt-live' } })
)
await settle()
expect(firstClient.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(1)
const first = current
const held = first.issue('model', 'gpt-fast', null)
await act(async () =>
root.update(
element(
boundary === 'session' ? 'scope-B' : 'scope-A',
boundary === 'client' ? replacement.client : firstClient.client
)
)
)
await expect(held.result).resolves.toEqual({ kind: 'superseded' })
act(() => first.settle(held, { kind: 'accepted', options: { model: 'gpt-fast' } }))
await expect(first.issue('model', 'dead', null).result).resolves.toEqual({ kind: 'superseded' })
expect(current).not.toBe(first)
const latest = setups.at(-1)!
act(() =>
latest.controller.settle(latest.operation, {
kind: 'accepted',
options: { model: 'gpt-live' }
})
)
await settle()
expect(firstClient.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(
boundary === 'session' ? 2 : 1
)
expect(replacement.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(
boundary === 'client' ? 1 : 0
)
const last = current
const unmounted = last.issue('model', 'gpt-fast', null)
await act(async () => root.unmount())
await expect(unmounted.result).resolves.toEqual({ kind: 'superseded' })
await expect(last.issue('model', 'dead', null).result).resolves.toEqual({ kind: 'superseded' })
}
)
it('reads the new physical path after an idle connected-to-connected migration without an operation wake', async () => {
const old = optionsClient(queuedReads(OPTIONS))
const answers: ReturnType<typeof deferred<AgentSessionOptionsResult>>[] = []
let cause = 'connected-callback'
const causes: string[] = []
const next = optionsClient(() => {
causes.push(cause)
const answer = deferred<AgentSessionOptionsResult>()
answers.push(answer)
return answer.promise
})
const client = createStableLogicalRpcClient(old.client, 'lan')
const { mutate } = recordingMutate(async () => ({ status: 'rejected' }))
const view = await mountOptions({ ...BASE, client, mutate })
expect(old.optionReads()).toBe(1)
expect(view.current().conversationCommands).toEqual([])
await act(async () => {
await client.migrateTo(next.client, 'relay')
cause = 'generation-read-effect'
})
await settle()
expect(next.optionReads()).toBeGreaterThanOrEqual(1)
expect(next.optionReads()).toBeLessThanOrEqual(2)
// The contact callback can start one read before React installs the generation-keyed reader.
expect(causes.filter((entry) => entry === 'connected-callback').length).toBeLessThanOrEqual(1)
expect(causes.filter((entry) => entry === 'generation-read-effect')).toHaveLength(1)
await act(async () => {
for (const answer of answers) {
answer.resolve({ ...OPTIONS, conversationCommands: ['compact'] })
}
})
await settle()
expect(view.current().conversationCommands).toEqual(['compact'])
expect(next.methods('agentSession.setOption')).toHaveLength(0)
await view.unmount()
client.close()
})
it.each(['current', 'failed-then-wake', 'true-end'] as const)(
'settles a late refusal after a physical reset without losing progress (%s)',
async (schedule) => {
const old = optionsClient(queuedReads(OPTIONS))
const reads: ReturnType<typeof Promise.withResolvers<AgentSessionOptionsResult>>[] = []
let cause = 'connected-callback'
const causes: string[] = []
const next = optionsClient(() => {
causes.push(cause)
const read = Promise.withResolvers<AgentSessionOptionsResult>()
reads.push(read)
return read.promise
})
const client = createStableLogicalRpcClient(old.client, 'lan')
const reply = deferred<{ status: 'rejected' }>()
const { calls, mutate } = recordingMutate(() => reply.promise)
const view = await mountOptions({ ...BASE, client, mutate, sendSelection: true })
let picking!: Promise<boolean>
act(() => {
picking = view.current().setStructuredOption('model', 'gpt-fast')
})
await act(async () => {
await client.migrateTo(next.client, 'relay')
cause = 'generation-read-effect'
})
const current: AgentSessionOptionsResult = {
...OPTIONS,
current: { model: 'gpt-live', effort: 'high' },
conversationCommands: ['compact']
}
await act(async () => {
for (const read of reads) {
if (schedule === 'failed-then-wake') {
read.reject(new Error('offline'))
} else {
read.resolve(current)
}
}
})
if (schedule === 'true-end') {
cause = 'replacement-session-read'
await view.rerender({ sessionId: 'replacement' })
}
const readsBeforeReply = next.optionReads()
cause = 'late-result-revalidation'
await act(async () => reply.resolve({ status: 'rejected' }))
await expect(picking).resolves.toBe(schedule === 'true-end')
expect(view.current().pendingOptionId).toBeNull()
expect(calls).toHaveLength(1)
expect(next.optionReads()).toBeLessThanOrEqual(schedule === 'current' ? 2 : 3)
const requested = next.methods('agentSession.options').map(({ params }, index) => ({
...OptionsParams.parse(params),
cause: causes[index]
}))
expect(requested).toHaveLength(causes.length)
// Migration and replacement install different session readers; each trigger issues once.
for (const reason of [
'connected-callback',
'generation-read-effect',
'replacement-session-read',
'late-result-revalidation'
]) {
expect(causes.filter((entry) => entry === reason).length).toBeLessThanOrEqual(1)
}
expect(causes).toContain('generation-read-effect')
for (const request of requested) {
expect(request.sessionId).toBe(
request.cause === 'replacement-session-read' ? 'replacement' : BASE.sessionId
)
}
if (schedule === 'true-end') {
expect(requested.filter((request) => request.cause === 'replacement-session-read')).toEqual([
{ sessionId: 'replacement', cause: 'replacement-session-read' }
])
expect(next.optionReads()).toBe(readsBeforeReply)
expect(causes).not.toContain('late-result-revalidation')
}
expect(next.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(0)
if (schedule === 'failed-then-wake') {
expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-live')
const beforeWake = reads.length
cause = 'status-wake'
await view.rerender({ optionStamp: 'legitimate-status-wake' })
expect(reads.length).toBe(beforeWake + 1)
await act(async () => reads.at(-1)!.resolve(current))
}
if (schedule !== 'true-end') {
expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-live')
expect(view.current().conversationCommands).toEqual(['compact'])
expect(view.current().sendSelection()).toMatchObject({
selection: { model: 'gpt-live', effort: 'high' }
})
} else {
expect(view.current().sendSelection()).toEqual({ selection: {} })
}
await view.unmount()
client.close()
}
)
it.each([false, true])(
'never promotes permission or older model busy state after settlement (permission first=%s)',
async (permissionFirst) => {
const idle = deferred<AgentSessionOptionsResult>()
let reads = 0
const rpc = optionsClient(() =>
++reads === 1
? Promise.resolve({ ...OPTIONS, current: { ...OPTIONS.current, permissionMode: 'ask' } })
: idle.promise
)
const replies = [
deferred<ReturnType<typeof accepted>>(),
deferred<ReturnType<typeof accepted>>()
]
const { calls, mutate } = recordingMutate((index) => replies[index].promise)
const view = await mountOptions({ ...BASE, client: rpc.client, mutate, sendSelection: true })
act(() => {
void view.current().setStructuredOption('model', 'gpt-fast')
})
await act(async () =>
expect(await view.current().setStructuredOption('model', 'gpt-live')).toBe(false)
)
act(() => {
void view.current().setStructuredOption('permissionMode', 'full-access')
})
expect(view.current().pendingOptionId).toBeNull()
for (const index of permissionFirst ? [1, 0] : [0, 1]) {
await act(async () => replies[index].resolve(acceptedWrite(calls[index])))
expect(view.current().pendingOptionId).toBeNull()
expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-fast')
expect(currentValueOf(view.current().optionSnapshot, 'permissionMode')).toBe('full-access')
expect(view.current().sendSelection()).toMatchObject({ selection: { model: 'gpt-fast' } })
}
expect(calls.map((call) => call.fields)).toEqual([
{ key: 'model', value: 'gpt-fast' },
{ key: 'permissionMode', value: 'full-access' }
])
await view.unmount()
}
)
it('bounds real phone permission projection work with 10 and 100 retained operations', async () => {
const measurements: number[] = []
for (const count of [10, 100]) {
const factory = vi.spyOn(selectionOperations, 'createStructuredSessionSelectionController')
const blocked = deferred<AgentSessionOptionsResult>()
let reads = 0
const rpc = optionsClient(() =>
++reads === 1
? Promise.resolve({ ...OPTIONS, current: { ...OPTIONS.current, permissionMode: 'ask' } })
: blocked.promise
)
const replies = Array.from({ length: count }, () => deferred<ReturnType<typeof accepted>>())
const { calls, mutate } = recordingMutate((index) => replies[index].promise)
const view = await mountOptions({ ...BASE, client: rpc.client, mutate })
const result = factory.mock.results.at(-1)!
if (result.type !== 'return') {
throw new Error('real hook did not construct its owner')
}
const controller = result.value
const issue = vi.spyOn(controller, 'issue')
let visits = 0
for (let index = 0; index < count; index++) {
const before = visits
act(() => {
void view.current().setStructuredOption('permissionMode', index % 2 ? 'ask' : 'full-access')
})
expect(visits - before).toBeLessThanOrEqual(40)
const issued = issue.mock.results.at(-1)!
if (issued.type !== 'return') {
throw new Error('permission not admitted')
}
const operation = issued.value
for (const key of ['identity', 'key', 'transmitted', 'outcome'] as const) {
let value = operation[key]
Object.defineProperty(operation, key, {
configurable: true,
get() {
visits++
return value
},
set(next) {
value = next
}
})
}
}
const issueVisits = visits
expect(issueVisits).toBeLessThanOrEqual(40 * count)
visits = 0
for (let index = count - 1; index >= 0; index--) {
const before = visits
await act(async () => replies[index].resolve(acceptedWrite(calls[index])))
expect(view.current().pendingOptionId).toBeNull()
expect(visits - before).toBeLessThanOrEqual(40)
}
expect(calls).toHaveLength(count)
expect(visits).toBeLessThanOrEqual(40 * count)
measurements.push(issueVisits + visits)
await view.unmount()
issue.mockRestore()
factory.mockRestore()
}
expect(measurements[1]).toBeLessThanOrEqual(measurements[0] * 11)
})
@@ -1,9 +1,15 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire'
import type {
AgentSessionOptionResult,
AgentSessionOptionsResult
} from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
import type {
StructuredAgentSessionMutate,
StructuredAgentSessionMutationResult
} from './mobile-structured-agent-session-rpc'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
function optionsFor(permissionMode?: string): AgentSessionOptionsResult {
@@ -71,6 +77,7 @@ async function mountOptions(props: ProbeProps) {
probe.hostAdvertisesPermissionModes === null
? null
: {
sendOptions: false,
permissionModes: probe.hostAdvertisesPermissionModes ?? true,
permissionOwn: probe.permissionOwn,
statusFeed: false
@@ -89,6 +96,7 @@ async function mountOptions(props: ProbeProps) {
})
await settle()
return {
pending: () => rendered.current?.pendingOptionId,
offered: () => {
const entry = rendered.current?.optionSnapshot.find(
(descriptor) => descriptor.id === 'permissionMode'
@@ -107,11 +115,15 @@ async function mountOptions(props: ProbeProps) {
})
await settle()
},
pick: async (mode: string) => {
pick: async (mode: string, waitForResult = false) => {
let result!: Promise<boolean>
await act(async () => {
void rendered.current?.setStructuredOption('permissionMode', mode)
result = rendered.current!.setStructuredOption('permissionMode', mode)
})
await settle()
if (waitForResult) {
return result
}
},
unmount: () => act(async () => renderer?.unmount())
}
@@ -128,6 +140,9 @@ describe('the permission pill against a host predating per-chat modes', () => {
})
expect(options.permission()).toBe('host')
expect(options.offered()).toEqual(['host'])
vi.mocked(mutate).mockClear()
expect(await options.pick('own', true)).toBe(false)
expect(mutate).not.toHaveBeenCalled()
await options.unmount()
})
@@ -151,6 +166,9 @@ describe('the permission pill against a host predating per-chat modes', () => {
hostAdvertisesPermissionModes: null
})
expect(options.permission()).toBeNull()
vi.mocked(mutate).mockClear()
expect(await options.pick('own', true)).toBe(false)
expect(mutate).not.toHaveBeenCalled()
await options.unmount()
})
})
@@ -218,14 +236,39 @@ describe('the permission mode on the phone', () => {
})
it('shows a pick at once while its write waits behind a start, and stays pickable', async () => {
vi.mocked(mutate).mockImplementation(() => new Promise(() => {}))
const first =
Promise.withResolvers<StructuredAgentSessionMutationResult<AgentSessionOptionResult>>()
const second =
Promise.withResolvers<StructuredAgentSessionMutationResult<AgentSessionOptionResult>>()
vi.mocked(mutate)
.mockReset()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const rpc = optionsClient('ask')
const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' })
const send = rpc.client.sendRequest
rpc.client.sendRequest = (method, params, config) =>
method === 'agentSession.options' ? new Promise(() => {}) : send(method, params, config)
await options.pick('full-access')
expect(options.permission()).toBe('full-access')
await options.pick('auto')
expect(options.permission()).toBe('auto')
expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2)
for (const [reply, value] of [
[second, 'auto'],
[first, 'full-access']
] as const) {
await act(async () =>
reply.resolve({
status: 'accepted',
value: { key: 'permissionMode', value, options: { permissionMode: value } },
sameFence: true
})
)
expect(options.pending()).toBeNull()
expect(options.offered()).toContain('auto')
expect(options.permission()).toBe('auto')
}
await options.unmount()
})
@@ -0,0 +1,238 @@
import { createElement, useLayoutEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { vi } from 'vitest'
import type {
AgentSessionModelCatalogResult,
AgentSessionOptionResult,
AgentSessionOptionsResult
} from '../../../src/shared/agent-session-wire'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat/input/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
import type {
StructuredAgentSessionMutate,
StructuredAgentSessionMutationResult
} from './mobile-structured-agent-session-rpc'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
const OPTIONS: AgentSessionOptionsResult = {
models: [
{
id: 'gpt-live',
label: 'GPT Live',
isDefault: true,
defaultEffort: 'medium',
efforts: [
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
]
},
{
id: 'gpt-fast',
label: 'GPT Fast',
isDefault: false,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' }
]
}
],
current: { model: 'gpt-live', effort: 'medium' }
}
const FAST_OPTIONS: AgentSessionOptionsResult = {
...OPTIONS,
models: OPTIONS.models.map((model) => ({ ...model, supportsFastMode: true })),
fastModeSupport: { supported: true },
current: { ...OPTIONS.current, fastMode: false, confirmed: ['fastMode'] }
}
const FAST_OPTIONS_ON: AgentSessionOptionsResult = {
...FAST_OPTIONS,
current: { ...FAST_OPTIONS.current, fastMode: true }
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((accept) => {
resolve = accept
})
return { promise, resolve }
}
function rpcSuccess(result: unknown) {
return { id: 'rpc-1', ok: true as const, result, _meta: { runtimeId: 'runtime-1' } }
}
type SentRequest = { method: string; params: unknown }
function optionsClient(
reads: () => Promise<AgentSessionOptionsResult>,
catalog?: (params: unknown) => Promise<AgentSessionModelCatalogResult>
) {
const sent: SentRequest[] = []
const client: RpcClient = {
sendRequest: async (method: string, params?: unknown) => {
sent.push({ method, params })
if (method === 'agentSession.modelCatalog') {
// Without a scripted catalog, the host predates the method.
return catalog
? rpcSuccess(await catalog(params))
: {
id: 'rpc-1',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
}
return rpcSuccess(method === 'agentSession.options' ? await reads() : {})
},
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
const methods = (name: string) => sent.filter((entry) => entry.method === name)
return { client, sent, methods, optionReads: () => methods('agentSession.options').length }
}
function queuedReads(...results: AgentSessionOptionsResult[]) {
let index = 0
return () => Promise.resolve(results[Math.min(index++, results.length - 1)]!)
}
type MutateCall = { method: string; fields: Record<string, unknown> }
// Use an untyped mock: a concrete fixture cannot satisfy arbitrary TValue from the generic mutation caller.
function recordingMutate(
next: (call: number) => Promise<StructuredAgentSessionMutationResult<AgentSessionOptionResult>>
) {
const calls: MutateCall[] = []
const mock = vi.fn()
mock.mockImplementation(
(method: string, _fingerprintMethod: string, fields: Record<string, unknown>) => {
calls.push({ method, fields })
return next(calls.length - 1)
}
)
const mutate: StructuredAgentSessionMutate = mock
return { calls, mutate }
}
function accepted(
value: AgentSessionOptionResult,
sameFence: boolean
): StructuredAgentSessionMutationResult<AgentSessionOptionResult> {
return { status: 'accepted', value, sameFence }
}
export type Controller = ReturnType<typeof useMobileStructuredAgentOptions>
type ProbeProps = {
agent: string | null
client: RpcClient | null
sessionId: string | null
fence: number | null
mutate: StructuredAgentSessionMutate
optionStamp?: string
turnId?: string | null
sendSelection?: boolean
coherentCatalog?: boolean
permissionOwn?: boolean
onRender: (controller: Controller) => void
}
function Probe(props: ProbeProps): null {
const controller = useMobileStructuredAgentOptions({
agent: props.agent,
client: props.client,
sessionId: props.sessionId,
enabled: true,
fence: props.fence,
mutate: props.mutate,
turnId: props.turnId ?? null,
...(props.optionStamp ? { optionStamp: props.optionStamp } : {}),
hostSupport: {
permissionModes: true,
permissionOwn: props.permissionOwn,
sendOptions: false,
statusFeed: false,
sendSelection: props.sendSelection,
coherentModelCatalog: props.coherentCatalog
}
})
useLayoutEffect(() => props.onRender(controller), [controller, props.onRender])
return null
}
async function settle() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function mountOptions(props: Omit<ProbeProps, 'onRender'>) {
let rendered: Controller | null = null
const onRender = (controller: Controller) => {
rendered = controller
}
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(createElement(Probe, { ...props, onRender }))
})
await settle()
const current = (): Controller => {
if (!rendered) {
throw new Error('probe never rendered')
}
return rendered
}
return {
current,
rerender: async (next: Partial<Omit<ProbeProps, 'onRender'>>) => {
await act(async () => {
renderer?.update(createElement(Probe, { ...props, ...next, onRender }))
})
await settle()
},
unmount: async () => {
await act(async () => renderer?.unmount())
}
}
}
function descriptorFor(
snapshot: readonly SessionOptionDescriptor[],
id: string
): SessionOptionDescriptor | undefined {
return snapshot.find((entry) => entry.id === id)
}
function currentValueOf(snapshot: readonly SessionOptionDescriptor[], id: string) {
const kind = descriptorFor(snapshot, id)?.kind
return kind && 'currentValue' in kind ? kind.currentValue : undefined
}
const BASE = { agent: 'codex', sessionId: 'session-1', fence: 1 } as const
export {
settle,
OPTIONS,
FAST_OPTIONS,
FAST_OPTIONS_ON,
deferred,
rpcSuccess,
optionsClient,
queuedReads,
recordingMutate,
accepted,
mountOptions,
descriptorFor,
currentValueOf,
BASE
}
@@ -1,215 +1,28 @@
import { createElement, useLayoutEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { act } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type {
AgentSessionModelCatalogResult,
AgentSessionOptionResult,
AgentSessionOptionsResult
} from '../../../src/shared/agent-session-wire'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat/input/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
import type {
StructuredAgentSessionMutate,
StructuredAgentSessionMutationResult
} from './mobile-structured-agent-session-rpc'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc'
import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions'
const OPTIONS: AgentSessionOptionsResult = {
models: [
{
id: 'gpt-live',
label: 'GPT Live',
isDefault: true,
defaultEffort: 'medium',
efforts: [
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
]
},
{
id: 'gpt-fast',
label: 'GPT Fast',
isDefault: false,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' }
]
}
],
current: { model: 'gpt-live', effort: 'medium' }
}
const FAST_OPTIONS: AgentSessionOptionsResult = {
...OPTIONS,
models: OPTIONS.models.map((model) => ({ ...model, supportsFastMode: true })),
fastModeSupport: { supported: true },
current: { ...OPTIONS.current, fastMode: false, confirmed: ['fastMode'] }
}
const FAST_OPTIONS_ON: AgentSessionOptionsResult = {
...FAST_OPTIONS,
current: { ...FAST_OPTIONS.current, fastMode: true }
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((accept) => {
resolve = accept
})
return { promise, resolve }
}
function rpcSuccess(result: unknown) {
return { id: 'rpc-1', ok: true as const, result, _meta: { runtimeId: 'runtime-1' } }
}
type SentRequest = { method: string; params: unknown }
function optionsClient(
reads: () => Promise<AgentSessionOptionsResult>,
catalog?: (params: unknown) => Promise<AgentSessionModelCatalogResult>
) {
const sent: SentRequest[] = []
const client: RpcClient = {
sendRequest: async (method: string, params?: unknown) => {
sent.push({ method, params })
if (method === 'agentSession.modelCatalog') {
// Without a scripted catalog, the host predates the method.
return catalog
? rpcSuccess(await catalog(params))
: {
id: 'rpc-1',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
}
return rpcSuccess(method === 'agentSession.options' ? await reads() : {})
},
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
const methods = (name: string) => sent.filter((entry) => entry.method === name)
return { client, sent, methods, optionReads: () => methods('agentSession.options').length }
}
function queuedReads(...results: AgentSessionOptionsResult[]) {
let index = 0
return () => Promise.resolve(results[Math.min(index++, results.length - 1)]!)
}
type MutateCall = { method: string; fields: Record<string, unknown> }
// Use an untyped mock: a concrete fixture cannot satisfy arbitrary TValue from the generic mutation caller.
function recordingMutate(
next: (call: number) => Promise<StructuredAgentSessionMutationResult<AgentSessionOptionResult>>
) {
const calls: MutateCall[] = []
const mock = vi.fn()
mock.mockImplementation(
(method: string, _fingerprintMethod: string, fields: Record<string, unknown>) => {
calls.push({ method, fields })
return next(calls.length - 1)
}
)
const mutate: StructuredAgentSessionMutate = mock
return { calls, mutate }
}
function accepted(
value: AgentSessionOptionResult,
sameFence: boolean
): StructuredAgentSessionMutationResult<AgentSessionOptionResult> {
return { status: 'accepted', value, sameFence }
}
type Controller = ReturnType<typeof useMobileStructuredAgentOptions>
type ProbeProps = {
agent: string | null
client: RpcClient | null
sessionId: string | null
fence: number | null
mutate: StructuredAgentSessionMutate
optionStamp?: string
turnId?: string | null
onRender: (controller: Controller) => void
}
function Probe(props: ProbeProps): null {
const controller = useMobileStructuredAgentOptions({
agent: props.agent,
client: props.client,
sessionId: props.sessionId,
enabled: true,
fence: props.fence,
mutate: props.mutate,
turnId: props.turnId ?? null,
...(props.optionStamp ? { optionStamp: props.optionStamp } : {}),
hostSupport: { permissionModes: true, statusFeed: false }
})
useLayoutEffect(() => props.onRender(controller), [controller, props.onRender])
return null
}
async function settle() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function mountOptions(props: Omit<ProbeProps, 'onRender'>) {
let rendered: Controller | null = null
const onRender = (controller: Controller) => {
rendered = controller
}
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(createElement(Probe, { ...props, onRender }))
})
await settle()
const current = (): Controller => {
if (!rendered) {
throw new Error('probe never rendered')
}
return rendered
}
return {
current,
rerender: async (next: Partial<Omit<ProbeProps, 'onRender'>>) => {
await act(async () => {
renderer?.update(createElement(Probe, { ...props, ...next, onRender }))
})
await settle()
},
unmount: async () => {
await act(async () => renderer?.unmount())
}
}
}
function descriptorFor(
snapshot: readonly SessionOptionDescriptor[],
id: string
): SessionOptionDescriptor | undefined {
return snapshot.find((entry) => entry.id === id)
}
function currentValueOf(snapshot: readonly SessionOptionDescriptor[], id: string) {
const kind = descriptorFor(snapshot, id)?.kind
return kind && 'currentValue' in kind ? kind.currentValue : undefined
}
const BASE = { agent: 'codex', sessionId: 'session-1', fence: 1 } as const
import {
type Controller,
settle,
OPTIONS,
FAST_OPTIONS,
FAST_OPTIONS_ON,
deferred,
optionsClient,
queuedReads,
recordingMutate,
accepted,
mountOptions,
descriptorFor,
currentValueOf,
BASE
} from './use-mobile-structured-agent-options.test-fixture'
describe('useMobileStructuredAgentOptions fast mode', () => {
it('round-trips a boolean fastMode pick as the wire string and remembers the decoded pick', async () => {
@@ -269,69 +82,42 @@ describe('useMobileStructuredAgentOptions fast mode', () => {
await harness.unmount()
})
it('offers no Fast row when the session reports fast mode unsupported', async () => {
const client = optionsClient(
queuedReads({ ...FAST_OPTIONS, fastModeSupport: { supported: false, reason: 'account' } })
)
it.each([
[
'session reports unsupported',
{ ...FAST_OPTIONS, fastModeSupport: { supported: false, reason: 'account' } }
],
['model capability is unknown', { ...FAST_OPTIONS, models: OPTIONS.models }]
] as const)('offers no Fast row when %s', async (_reason, answer) => {
const client = optionsClient(queuedReads(answer))
const { mutate } = recordingMutate(async () => ({ status: 'rejected' }))
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
expect(descriptorFor(harness.current().optionSnapshot, 'fastMode')).toBeUndefined()
await harness.unmount()
})
it('offers no Fast row when the model capability is unknown', async () => {
const client = optionsClient(
queuedReads({
...FAST_OPTIONS,
models: OPTIONS.models
})
)
const { mutate } = recordingMutate(async () => ({ status: 'rejected' }))
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
expect(descriptorFor(harness.current().optionSnapshot, 'fastMode')).toBeUndefined()
await harness.unmount()
})
})
describe('useMobileStructuredAgentOptions post-write refresh', () => {
it('reads options back after an accepted same-fence write and applies the refreshed value', async () => {
const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON))
const { mutate } = recordingMutate(async () =>
accepted({ key: 'fastMode', value: 'true', options: {} }, true)
)
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
expect(client.optionReads()).toBe(1)
expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(false)
await act(async () => {
await harness.current().setStructuredOption('fastMode', true)
})
await settle()
expect(client.optionReads()).toBe(2)
expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(true)
await harness.unmount()
})
it('skips the refresh when the write landed against a different fence', async () => {
const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON))
const { mutate } = recordingMutate(async () =>
accepted({ key: 'fastMode', value: 'true', options: {} }, false)
)
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
await act(async () => {
await harness.current().setStructuredOption('fastMode', true)
})
await settle()
expect(client.optionReads()).toBe(1)
expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(false)
await harness.unmount()
})
it.each([true, false])(
'revalidates an accepted operation with sameFence=%s',
async (sameFence) => {
const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON))
// The reply settles the operation; only the query installs the chat's selection.
const { mutate } = recordingMutate(async () =>
accepted({ key: 'fastMode', value: 'true', options: {} }, sameFence)
)
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
expect(client.optionReads()).toBe(1)
expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(false)
await act(async () => {
await harness.current().setStructuredOption('fastMode', true)
})
await settle()
expect(client.optionReads()).toBe(2)
expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(true)
await harness.unmount()
}
)
})
describe('useMobileStructuredAgentOptions reads', () => {
@@ -709,23 +495,149 @@ describe('useMobileStructuredAgentOptions after a lost pick reply', () => {
: null
}
it('keeps the confirmed model after a lost pick reply, as desktop does', async () => {
const client = optionsClient(() => Promise.resolve(answer('m1', 'priority')))
it('draws the tier the host reconciled to, stays usable through a failed read, and takes the reconnect read', async () => {
const pendingRead = deferred<AgentSessionOptionsResult>()
const reads = [
() => Promise.resolve(answer('m1', 'priority')),
() => Promise.reject(new Error('terminal reconciliation offline')),
() => Promise.reject(new Error('status reconciliation offline')),
() => pendingRead.promise,
() => Promise.resolve(answer('m2', 'flex'))
]
const client = optionsClient(() =>
(reads.shift() ?? (() => Promise.resolve(answer('m2', 'flex'))))()
)
const { mutate } = recordingMutate(async () => ({ status: 'unknown' }))
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
let picked = true
await act(async () => {
picked = await harness.current().setStructuredOption('model', 'm2')
})
expect(picked).toBe(false)
expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m1')
expect(speed(harness.current())?.value).toBe('priority')
// The host switched and set Standard, but its reply never arrived.
await act(async () => {
await harness.current().setStructuredOption('model', 'm2')
})
expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2')
expect(speed(harness.current())).toEqual({ value: 'default', choices: ['default', 'flex'] })
expect(harness.current().pendingOptionId).toBeNull()
expect(client.optionReads()).toBe(2) // The terminal wake consumed its failed query.
// The status-triggered read fails independently: nothing changes.
await harness.rerender({ optionStamp: 'status-1' })
expect(speed(harness.current())?.value).toBe('default')
// A read in flight when the user picks Flex answers for the old state and is dropped.
await harness.rerender({ optionStamp: 'status-2' })
let picked = false
await act(async () => {
picked = await harness.current().setStructuredOption('serviceTier', 'flex')
})
expect(picked).toBe(true)
await act(async () => pendingRead.resolve(answer('m1', 'priority')))
await settle()
expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2')
expect(speed(harness.current())?.value).toBe('flex')
// Reconnected, the host's answer stands.
await harness.rerender({ optionStamp: 'status-3' })
expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2')
expect(speed(harness.current())?.value).toBe('flex')
expect(client.optionReads()).toBe(6)
await harness.unmount()
})
})
it('phone retains a queried seed-catalog base across its fence until later metadata arrives', async () => {
const catalog = deferred<AgentSessionModelCatalogResult>()
let reads = 0
const client = optionsClient(
() =>
++reads === 1
? Promise.resolve({
...OPTIONS,
current: {
model: 'gpt-live',
effort: 'high',
defaulted: ['model'],
acceptedInputSources: { effort: 'saved' }
}
})
: deferred<AgentSessionOptionsResult>().promise,
() => catalog.promise
)
const { mutate } = recordingMutate(async () => accepted({ key: 'effort', value: 'low' }, true))
rememberMobileCreatedStructuredSession('answered-retention', 'worktree')
const harness = await mountOptions({
...BASE,
sessionId: 'answered-retention',
client: client.client,
mutate,
sendSelection: true,
coherentCatalog: true
})
expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } })
await harness.rerender({ fence: 2 })
expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } })
await act(async () =>
catalog.resolve({
origin: 'probe',
fetchedAt: 1,
models: OPTIONS.models,
listingNamesConfiguredModel: true
})
)
await settle()
expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } })
await harness.unmount()
})
it('phone captures accepted echoed inputs, preserves false, and freezes empty vs legacy presence', async () => {
let answer: AgentSessionOptionsResult = {
...FAST_OPTIONS,
models: [
{
id: 'opus',
label: 'Opus',
isDefault: true,
supportsFastMode: true,
efforts: [{ value: 'high', label: 'High' }]
}
],
current: {
model: 'opus',
effort: 'high',
fastMode: false,
confirmed: ['fastMode'],
acceptedInputSources: {}
}
}
const client = optionsClient(async () => answer)
const { mutate } = recordingMutate(async () => accepted({ key: 'model', value: 'opus' }, true))
const harness = await mountOptions({
...BASE,
agent: 'claude',
client: client.client,
mutate,
sendSelection: true
})
expect(harness.current().sendSelection()).toEqual({ selection: {} })
answer = {
...answer,
current: { ...answer.current, acceptedInputSources: { model: 'saved', fastMode: 'policy' } }
}
await harness.rerender({ optionStamp: 'saved-origin' })
await settle()
const captured = harness.current().sendSelection()
expect(captured).toEqual({ selection: { model: 'opus', fastMode: 'false' } })
answer = {
...answer,
current: { ...answer.current, effort: 'high', acceptedInputSources: { effort: 'saved' } }
}
await harness.rerender({ optionStamp: 'other-client' })
await settle()
expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } })
expect(captured).toEqual({ selection: { model: 'opus', fastMode: 'false' } })
await harness.unmount()
})
it('refuses a typed literal at an unresolved fence and accepts it when that fence is ready with no menu', async () => {
const pending = deferred<AgentSessionOptionsResult>()
let reads = 0
@@ -7,14 +7,13 @@ import type { AgentSessionOptionResult } from '../../../src/shared/agent-session
import type { SessionOptionValue } from '../../../src/shared/native-chat/input/native-chat-session-options'
import {
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
lockedStructuredAgentSessionOptionSnapshot,
structuredAgentSessionOptionPicks
} from '../../../src/shared/structured-agent-session-options'
import {
structuredAgentSessionOptionView,
structuredAgentSessionSendSelection,
structuredAgentSessionMessageSelection,
structuredAgentSessionShownSnapshot
} from '../../../src/shared/structured-agent-session-option-view'
import {
@@ -25,17 +24,9 @@ import { legacyHostPermissionDescriptor } from '../../../src/shared/structured-a
import type { RpcClient } from '../transport/rpc-client'
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
import { persistMobileStructuredOptionPicks } from './native-chat/input/mobile-native-chat-session-option-persistence'
import { useMobileHostModelCatalogUpgrade } from './use-mobile-host-model-catalog-upgrade'
import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions'
import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec'
import {
beginStructuredOptionPick,
isLatestStructuredOptionPick,
settleStructuredOptionPick,
structuredOptionPickCommit,
structuredOptionPickBlockingId
} from '../../../src/shared/structured-agent-session-option-pick-book'
import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface'
const NO_COMMANDS: readonly AgentSessionConversationCommand[] = []
@@ -46,12 +37,17 @@ export function useMobileStructuredAgentOptions(args: {
sessionId: string | null
enabled: boolean
fence: number | null
turnId: string | null
mutate: StructuredAgentSessionMutate
optionStamp?: string
turnId: string | null
hostSupport: Pick<
StructuredAgentSessionHostSupport,
'permissionModes' | 'permissionOwn' | 'coherentModelCatalog' | 'statusFeed'
| 'permissionModes'
| 'permissionOwn'
| 'coherentModelCatalog'
| 'statusFeed'
| 'sendSelection'
| 'sendOptions'
> | null
}) {
const { agent, client, enabled, fence, mutate, optionStamp, sessionId, turnId, hostSupport } =
@@ -64,21 +60,21 @@ export function useMobileStructuredAgentOptions(args: {
() => (sessionId ? mobileCreatedStructuredSession(sessionId) : undefined),
[sessionId]
)
// An options answer made before the account's listing landed listed none of it.
const {
projectPermission,
optionState,
optionStateRef,
activeOptionRecordRef,
optionPicks,
providerStarting,
updateOptionState,
conversationSupport,
imageSupport,
awaitingOptions,
writes,
readRef,
receiveCatalog,
isCurrentContext
isCurrentContext,
controller,
reread
} = useMobileStructuredOptionState({
agent,
enabled,
@@ -89,6 +85,7 @@ export function useMobileStructuredAgentOptions(args: {
turnId,
client,
optionStamp,
sendSelection: hostSupport?.sendSelection === true,
newLaunch: createdHere !== undefined,
worktree: createdHere?.worktree,
coherentCatalog: hostSupport?.coherentModelCatalog === true
@@ -125,14 +122,19 @@ export function useMobileStructuredAgentOptions(args: {
interactionReadyRef.current = !awaitingOptions
lockSnapshotRef.current = lockSnapshot
})
const shown = lockSnapshot(
structuredAgentSessionShownSnapshot(
const readOptionState = useCallback(
() =>
projectPermission({
...optionState,
...structuredAgentSessionOptionView(
optionStateRef.current,
undefined,
controller.overlay()
),
permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null
})
)
}),
[controller, projectPermission, hostSupport?.permissionOwn]
)
const shown = lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState()))
const optionSnapshot = legacyHostPermissions
? [...shown, legacyHostPermissionDescriptor()]
: shown
@@ -143,128 +145,119 @@ export function useMobileStructuredAgentOptions(args: {
...optionStateRef.current,
permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null
})
const view = structuredAgentSessionOptionView(currentState, undefined, controller.overlay())
const pickState = {
...view,
pendingId:
providerStarting ||
id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ||
!controller.isPending(id)
? null
: id
}
const encoded = encodeStructuredAgentSessionOptionValue(id, value)
if (
(id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID &&
value === 'own' &&
!hostSupport?.permissionOwn) ||
!isCurrentContext() ||
awaitingOptions ||
!client ||
!sessionId ||
!optionCatalog ||
encoded === null ||
!canSetStructuredAgentSessionOption(
{
...structuredAgentSessionOptionView(currentState, undefined, {}),
pendingId: structuredOptionPickBlockingId(optionPicks.current, id, providerStarting)
},
pickState,
id,
value,
lockSnapshotRef.current(structuredAgentSessionShownSnapshot(currentState)),
lockSnapshotRef.current(
structuredAgentSessionShownSnapshot(
structuredAgentSessionOptionView(currentState, undefined, controller.overlay())
)
),
interactionReadyRef.current
)
) {
return false
}
const targetRecord = currentState.record
writes.current.count += 1
const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
const pick = beginStructuredOptionPick(optionPicks.current, id, encoded, shownAtOnce)
const sameRecord = (): boolean =>
isCurrentContext() && activeOptionRecordRef.current === targetRecord
const isCurrent = (): boolean =>
sameRecord() && isLatestStructuredOptionPick(optionPicks.current, pick)
if (shownAtOnce) {
updateOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOption(current, id, encoded)
: current
)
} else {
updateOptionState((current) => ({ ...current, pendingId: id }))
}
let readsBack = shownAtOnce
try {
const model = structuredAgentSessionOptionPicks(view, { [id]: encoded })[0]?.modelId ?? null
const operation = controller.issue(id, encoded, model)
updateOptionState((current) => ({
...current,
pendingId: providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ? null : id
}))
void controller.flush(async (pick) => {
// A queued pick never retargets a replacement runtime.
if (activeOptionRecordRef.current !== targetRecord) {
return { kind: 'superseded' }
}
const result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value: encoded }
{
key: pick.key,
value: pick.value
}
)
// As on desktop, a reply that never came is not a pick done.
if (result.status !== 'accepted') {
return false
}
const committed =
sameRecord() && result.sameFence
? structuredOptionPickCommit(optionPicks.current, pick, result.value.options)
: null
if (committed) {
updateOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOptionValues(current, committed)
: current
)
// Persist only accepted picks; uncertain optimism must not seed future launches with an option the provider may have refused.
if (agent) {
void persistMobileStructuredOptionPicks({
client,
agent,
picks: structuredAgentSessionOptionPicks(currentState, committed)
})
}
}
readsBack = result.sameFence
return true
} finally {
if (sameRecord()) {
settleStructuredOptionPick(optionPicks.current, pick)
}
if (isCurrent()) {
updateOptionState((current) =>
current.record === targetRecord && current.pendingId === id
? { ...current, pendingId: null }
: current
)
}
if (sameRecord()) {
writes.current.count -= 1
writes.current.readback ||= readsBack && isCurrent()
if (!writes.current.count && writes.current.readback) {
readRef.current?.()
}
return result.status === 'accepted'
? { kind: 'accepted', options: result.value.options ?? { [pick.key]: pick.value } }
: result.status === 'unknown'
? { kind: 'unknown' }
: { kind: 'refused' }
})
const outcome = await operation.result
if (activeOptionRecordRef.current === targetRecord) {
updateOptionState((current) => ({
...current,
pendingId:
current.pendingId !== null && controller.isPending(current.pendingId)
? current.pendingId
: null
}))
if (outcome.kind === 'refused') {
reread()
}
}
return outcome.kind !== 'refused'
},
[
agent,
awaitingOptions,
hostSupport?.permissionOwn,
projectPermission,
providerStarting,
client,
createdHere,
awaitingOptions,
controller,
mutate,
optionCatalog,
optionPicks,
providerStarting,
writes,
isCurrentContext,
reread,
sessionId,
updateOptionState
updateOptionState,
isCurrentContext
]
)
const sendSelection = useCallback(
() => structuredAgentSessionSendSelection(optionStateRef.current),
[]
)
// Controls and capture derive from the same queried base and transient operations.
const sendSelection = useCallback(() => {
const view = structuredAgentSessionOptionView(
optionStateRef.current,
undefined,
controller.overlay()
)
return hostSupport?.sendSelection
? { selection: structuredAgentSessionMessageSelection(view) }
: hostSupport?.sendOptions
? { options: structuredAgentSessionSendSelection(view) }
: {}
}, [controller, hostSupport?.sendSelection, hostSupport?.sendOptions])
const { optionPickerRequest, invokeStructuredOption, optionSurface } =
useMobileStructuredOptionSurface({
optionSnapshot,
demandModelCatalog,
lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot),
setStructuredOption,
optionStateRef,
projectPermission,
lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot)
readOptionState
})
return {
@@ -42,6 +42,21 @@ const asyncStorage = vi.hoisted(() => ({
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
function normalReply(method: string): RpcResponse {
if (method === 'agentSession.options') {
return ok({ models: [], current: {} })
}
if (method === 'agentSession.modelCatalog') {
return {
id: 'catalog',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
}
return ok({})
}
describe('mobile structured queued messages', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
@@ -92,17 +107,17 @@ describe('mobile structured queued messages', () => {
event: AgentSessionSubscribeEvent = snapshotEvent(),
sessionId?: string
): Promise<void> {
act(() => {
await act(async () => {
renderer = create(
createElement(Harness, { hostSupport, ...(sessionId ? { sessionId } : {}) })
)
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(event))
await act(async () => listener?.(event))
}
function unmountSession(): void {
act(() => renderer?.unmount())
async function unmountSession(): Promise<void> {
await act(async () => renderer?.unmount())
renderer = null
hook = null
listener = null
@@ -134,14 +149,12 @@ describe('mobile structured queued messages', () => {
asyncStorage.removeItem.mockImplementation(async (key: string) => {
stored.delete(key)
})
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
)
sendRequest.mockImplementation(async (method) => normalReply(method))
})
afterEach(() => {
afterEach(async () => {
vi.useRealTimers()
unmountSession()
await unmountSession()
})
it('reverts an optimistic relative move when the host reports a changed queue', async () => {
@@ -158,17 +171,19 @@ describe('mobile structured queued messages', () => {
await mountSession(CAPABLE, snapshotEvent({ queuedMessages: messages }))
expect(hook!.queued.move).toBeUndefined()
expect(calls('agentSession.queuedMessageMove')).toHaveLength(0)
unmountSession()
await unmountSession()
await mountSession(
{ ...CAPABLE, queuedMessageReorder: true },
snapshotEvent({ queuedMessages: messages })
)
let finish: (response: RpcResponse) => void = () => {}
sendRequest.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
const otherMethods = sendRequest.getMockImplementation()!
sendRequest.mockImplementation((method, params) =>
method === 'agentSession.queuedMessageMove'
? new Promise((resolve) => {
finish = resolve
})
: otherMethods(method, params)
)
let moving!: Promise<boolean>
await act(async () => {
@@ -216,7 +231,7 @@ describe('mobile structured queued messages', () => {
queued: { messageId: 'client-1', position: 1, state: 'waiting' }
})
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(CAPABLE)
await act(async () => {
@@ -245,7 +260,7 @@ describe('mobile structured queued messages', () => {
submission: acceptedSubmission('client-1')
})
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(CAPABLE)
await vi.waitFor(() =>
@@ -275,7 +290,7 @@ describe('mobile structured queued messages', () => {
}
})
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(LEGACY)
await act(async () => {
@@ -296,7 +311,7 @@ describe('mobile structured queued messages', () => {
let attempts = 0
sendRequest.mockImplementation(async (method, params) => {
if (method !== 'agentSession.send') {
return ok({ models: [], current: {} })
return normalReply(method)
}
if (++attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
@@ -308,7 +323,7 @@ describe('mobile structured queued messages', () => {
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('unknown')
})
unmountSession()
await unmountSession()
await mountSession(LEGACY)
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('accepted')
@@ -325,7 +340,7 @@ describe('mobile structured queued messages', () => {
let attempts = 0
sendRequest.mockImplementation(async (method, params) => {
if (method !== 'agentSession.send') {
return ok({ models: [], current: {} })
return normalReply(method)
}
if (++attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
@@ -467,7 +482,7 @@ describe('mobile structured queued messages', () => {
}
})
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(CAPABLE)
await act(async () => {
@@ -481,9 +496,7 @@ describe('mobile structured queued messages', () => {
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.queuedMessageSend' || method === 'agentSession.queuedMessageDelete'
? held.promise
: method === 'agentSession.options'
? ok({ models: [], current: {} })
: ok({})
: normalReply(method)
)
await mountSession(CAPABLE)
const presses: Promise<boolean>[] = []
@@ -507,7 +520,7 @@ describe('mobile structured queued messages', () => {
if (method === 'agentSession.queuedMessageDelete') {
return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(CAPABLE)
await act(async () => {
@@ -523,9 +536,7 @@ describe('mobile structured queued messages', () => {
? fieldsOf(params).messageId === 'draft-1'
? mutationOk({ deleted: true, messageId: 'draft-1' })
: mutationOk({ deleted: false, messageId: 'draft-2', disposition: 'missing' })
: method === 'agentSession.options'
? ok({ models: [], current: {} })
: ok({})
: normalReply(method)
)
await mountSession(
LEGACY,
@@ -542,7 +553,7 @@ describe('mobile structured queued messages', () => {
if (method === 'agentSession.queuedMessageDelete') {
return mutationOk({ deleted: true, messageId: 'draft-1' })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(
LEGACY,
@@ -565,7 +576,7 @@ describe('mobile structured queued messages', () => {
if (method === 'agentSession.queuedMessageDelete') {
return { id: 'request-1', ok: false, error: { code: 'runtime_error', message: 'boom' } }
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(
LEGACY,
@@ -584,7 +595,7 @@ describe('mobile structured queued messages', () => {
if (method === 'agentSession.queuedMessageDelete') {
return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(
LEGACY,
@@ -599,9 +610,7 @@ describe('mobile structured queued messages', () => {
})
it('Edit that copied nothing deletes nothing and does not report a copy', async () => {
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
)
sendRequest.mockImplementation(async (method) => normalReply(method))
await mountSession(
LEGACY,
snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] })
@@ -624,7 +633,7 @@ describe('mobile structured queued messages', () => {
if (method === 'agentSession.cancel') {
return mutationOk({ cancelled: true, turnId: 'turn-1' })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(
CAPABLE,
@@ -681,7 +690,7 @@ describe('mobile structured queued messages', () => {
})
: mutationOk({ resumed: true })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(
CAPABLE,
@@ -705,7 +714,7 @@ describe('mobile structured queued messages', () => {
if (method === 'agentSession.cancel') {
return mutationOk({ cancelled: true, turnId: 'turn-1' })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return normalReply(method)
})
await mountSession(LEGACY, snapshotEvent({ runningTurn: true }))
await act(async () => {
@@ -644,7 +644,7 @@ describe('useMobileStructuredAgentSession', () => {
expect(retryId).not.toBe(firstId)
})
it('does not commit a structured option change after unknown delivery, as desktop does', async () => {
it('keeps unknown intent until a current query, without accepted memory or resend', async () => {
act(() => {
renderer = create(createElement(Harness))
})
@@ -656,20 +656,38 @@ describe('useMobileStructuredAgentSession', () => {
settable: true
})
})
let reconcile!: (response: Awaited<ReturnType<typeof defaultSendRequest>>) => void
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.setOption') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
if (method === 'agentSession.options') {
return new Promise<Awaited<ReturnType<typeof defaultSendRequest>>>((resolve) => {
reconcile = resolve
})
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(false)
expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true)
})
const model = hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model')
expect(model?.kind).not.toMatchObject({ currentValue: 'gpt-slow' })
expect(model?.kind).toMatchObject({ currentValue: 'gpt-slow' })
expect(
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.setOption')
).toHaveLength(1)
expect(
sendRequest.mock.calls.filter(
([method]) => method === 'settings.mutateNativeChatSessionOptions'
)
).toHaveLength(0)
await act(async () => reconcile(await defaultSendRequest('agentSession.options')))
expect(hook!.optionSnapshot.find((row) => row.id === 'model')?.kind).toMatchObject({
currentValue: 'gpt-fast'
})
expect(onSendError).not.toHaveBeenCalled()
})
@@ -203,7 +203,7 @@ export function useMobileStructuredAgentSession(
sessionCommands: args.sessionCommands,
onSendError,
queueSendsNext,
sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined,
sendSelection: options.sendSelection,
takesSkills: hostSupport?.sendSkills === true
})
const { items, currentRequests, actionablePromptIds, status } = state
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from '
import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed'
import { projectRuntimePermission } from '../../../src/shared/structured-agent-session-permission-option'
import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session/agent-session-conversation-command'
import { useMobileStructuredSelectionController } from './use-mobile-structured-selection-controller'
import type {
AgentSessionOptionsResult,
AgentSessionModelCatalogResult
@@ -13,10 +14,6 @@ import type { RpcClient } from '../transport/rpc-client'
import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
import { usesInstalledAgentModelCatalog } from '../../../src/shared/agent-model-catalog-ownership'
import { cloneStructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state'
import {
createStructuredOptionPickBook,
resetStructuredOptionPickBook
} from '../../../src/shared/structured-agent-session-option-pick-book'
import {
createStructuredAgentSessionOptionState,
applyStructuredAgentSessionOptions,
@@ -28,6 +25,7 @@ import {
export function useMobileStructuredOptionState(args: {
client: RpcClient | null
optionStamp?: string
sendSelection?: boolean
newLaunch: boolean
worktree?: string
coherentCatalog?: boolean
@@ -49,6 +47,7 @@ export function useMobileStructuredOptionState(args: {
turnId,
client,
optionStamp,
sendSelection,
newLaunch,
worktree,
coherentCatalog: hostCoherentCatalog = false
@@ -64,7 +63,6 @@ export function useMobileStructuredOptionState(args: {
const summary = useSyncExternalStore(feed?.subscribe ?? (() => () => {}), () =>
sessionId ? feed?.getSnapshot().get(sessionId) : undefined
)
// As on desktop, a host still starting the provider holds option writes; picks show at once.
const providerStarting = summary?.hostExecutionPhase === 'starting'
const projectPermission = <T extends { reportedPermissionMode?: string | null }>(state: T): T =>
projectRuntimePermission(state, summary, fence)
@@ -74,7 +72,18 @@ export function useMobileStructuredOptionState(args: {
)
const optionStateRef = useRef(optionState)
const activeOptionRecordRef = useRef(optionState.record)
const optionPicks = useRef(createStructuredOptionPickBook())
const readEpoch = useRef(0)
const readRef = useRef<(() => void) | null>(null)
const reread = useCallback(() => {
readEpoch.current += 1
readRef.current?.()
}, [])
const { controller, clientGeneration } = useMobileStructuredSelectionController({
agent,
client,
sessionId,
onRevalidate: reread
})
const updateOptionState = useCallback(
(update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => {
const next = update(optionStateRef.current)
@@ -92,20 +101,24 @@ export function useMobileStructuredOptionState(args: {
() => contextRef.current.key === contextKey && contextRef.current.client === client,
[contextKey, client]
)
const writes = useRef({ count: 0, readback: false })
const optionIdentityRef = useRef(`${agent}:${sessionId}`)
const optionIdentityRef = useRef({ agent, sessionId, client, controller })
useEffect(() => {
const identity = `${agent}:${sessionId}`
const sameSession = optionIdentityRef.current === identity
optionIdentityRef.current = identity
const previousIdentity = optionIdentityRef.current
const sameSession =
previousIdentity.agent === agent &&
previousIdentity.sessionId === sessionId &&
previousIdentity.client === client &&
previousIdentity.controller === controller
optionIdentityRef.current = { agent, sessionId, client, controller }
const previous = optionStateRef.current
const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog)
// Retain account answers across fence changes instead of reviving placeholders.
const kept =
sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null
sameSession &&
(previous.optionsAnswerPresent ||
(previous.catalogSource !== 'seed' && previous.catalogSource !== null))
? {
...previous,
catalogSource: 'host' as const,
record: cloneStructuredAgentSessionOptionRecord(previous.record),
pendingId: null,
reportedPermissionMode: undefined
@@ -113,12 +126,21 @@ export function useMobileStructuredOptionState(args: {
: seeded
// Keep the chat’s mode visible through provider restarts.
const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept
writes.current = { count: 0, readback: false }
resetStructuredOptionPickBook(optionPicks.current)
readEpoch.current += 1
optionStateRef.current = next
activeOptionRecordRef.current = next.record
setOptionState(next)
}, [agent, enabled, fence, optionCatalog, sessionId, contextKey, client])
}, [
agent,
enabled,
fence,
optionCatalog,
sessionId,
contextKey,
client,
clientGeneration,
controller
])
const [conversationSupport, setConversationSupport] = useState<{
sessionId: string
@@ -134,7 +156,6 @@ export function useMobileStructuredOptionState(args: {
const awaitingOptions =
conversationSupport?.sessionId === sessionId && conversationSupport.fence !== fence
const readRef = useRef<(() => void) | null>(null)
const receiveCatalog = (
catalog: AgentSessionModelCatalogResult | null,
_event: 'demand' | 'progress' | 'settled'
@@ -153,7 +174,13 @@ export function useMobileStructuredOptionState(args: {
}
useEffect(() => {
// Read each turn and after provider start; before start only a resumed chat has model evidence.
if (!client || !sessionId || !enabled || !optionCatalog || (providerStarting && newLaunch)) {
if (
!client ||
!sessionId ||
!enabled ||
!optionCatalog ||
(providerStarting && newLaunch && !sendSelection)
) {
return
}
let stale = false
@@ -163,13 +190,10 @@ export function useMobileStructuredOptionState(args: {
if (stale || !isCurrentContext()) {
return
}
if (writes.current.count) {
writes.current.readback = true
return
}
writes.current.readback = false
const record = activeOptionRecordRef.current
const readGeneration = optionPicks.current.generation
const epoch = readEpoch.current
const generation = client.getGeneration?.()
const completed = controller.captureRead()
const result = await callAgentSession<AgentSessionOptionsResult>(
client,
'agentSession.options',
@@ -185,7 +209,7 @@ export function useMobileStructuredOptionState(args: {
acceptsImages: result.acceptsImages,
statesPermissionMode: result.current?.permissionMode !== undefined
})
if (optionPicks.current.generation !== readGeneration) {
if (readEpoch.current !== epoch || client.getGeneration?.() !== generation) {
return
}
setConversationSupport({ sessionId, fence, commands: result.conversationCommands ?? [] })
@@ -195,6 +219,7 @@ export function useMobileStructuredOptionState(args: {
coherentCatalog
})
)
controller.retire(completed)
}).catch(() => undefined)
}
readRef.current = request
@@ -205,10 +230,13 @@ export function useMobileStructuredOptionState(args: {
}
}, [
client,
clientGeneration,
newLaunch,
enabled,
optionCatalog,
optionStamp,
sendSelection,
controller,
isCurrentContext,
coherentCatalog,
providerStarting,
@@ -223,14 +251,13 @@ export function useMobileStructuredOptionState(args: {
conversationSupport,
imageSupport,
awaitingOptions,
writes,
readRef,
controller,
reread,
receiveCatalog,
isCurrentContext,
optionState,
optionStateRef,
activeOptionRecordRef,
optionPicks,
providerStarting,
updateOptionState
}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, type RefObject } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view'
import type { StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options'
import type {
@@ -12,10 +12,10 @@ export function useMobileStructuredOptionSurface(args: {
lockSnapshot: (snapshot: SessionOptionDescriptor[]) => SessionOptionDescriptor[]
optionSnapshot: SessionOptionDescriptor[]
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
optionStateRef: RefObject<StructuredAgentSessionOptionState>
projectPermission: (state: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
/** The latest option state, which a settled pick's snapshot is read from. */
readOptionState: () => StructuredAgentSessionOptionState
}) {
const { optionSnapshot, optionStateRef, setStructuredOption, demandModelCatalog } = args
const { optionSnapshot, readOptionState, setStructuredOption, demandModelCatalog } = args
const [optionPickerRequest, setOptionPickerRequest] = useState<{
id: string
sequence: number
@@ -35,13 +35,9 @@ export function useMobileStructuredOptionSurface(args: {
const setOption = useCallback(
async (id: string, value: SessionOptionValue) => {
await setStructuredOption(id, value)
return {
snapshot: args.lockSnapshot(
structuredAgentSessionShownSnapshot(args.projectPermission(optionStateRef.current))
)
}
return { snapshot: args.lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState())) }
},
[args.lockSnapshot, optionStateRef, setStructuredOption]
[args.lockSnapshot, readOptionState, setStructuredOption]
)
const optionSurface = useMemo<SessionOptionsSurface>(
@@ -0,0 +1,58 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { createStructuredSessionSelectionController } from '../../../src/shared/structured-agent-session-selection-operations'
import { persistMobileStructuredOptionPicks } from './native-chat/input/mobile-native-chat-session-option-persistence'
import type { RpcClient } from '../transport/rpc-client'
/** Temporary operations belong to one phone authority, above its queried host answer. */
export function useMobileStructuredSelectionController(args: {
agent: string | null
client: RpcClient | null
sessionId: string | null
onRevalidate: () => void
}) {
const { agent, client, sessionId, onRevalidate } = args
const clientGeneration = useSyncExternalStore(client?.onStateChange ?? (() => () => {}), () =>
client?.getGeneration?.()
)
const scope = useMemo(() => {
let attached = true
const controller = createStructuredSessionSelectionController(
(picks) => {
if (agent) {
void persistMobileStructuredOptionPicks({ client, agent, picks })
}
},
() => attached
)
return {
controller,
attach: () => {
attached = true
},
detach: () => {
attached = false
controller.abandon()
}
}
}, [agent, client, sessionId])
const { controller } = scope
useEffect(() => {
scope.attach()
return scope.detach
}, [scope])
const selectionRevision = useSyncExternalStore(controller.subscribe, controller.snapshot)
const terminal = controller.terminalSnapshot()
useEffect(() => {
if (terminal) {
onRevalidate()
}
}, [terminal, onRevalidate])
useEffect(() => {
return client?.onStateChange((state) => {
if (state === 'connected') {
onRevalidate()
}
})
}, [client, controller, onRevalidate])
return { controller, selectionRevision, clientGeneration }
}
@@ -9,7 +9,8 @@ import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-ses
import {
SkillRefLimitError,
structuredAgentSessionSendBody,
type StructuredAgentSessionAttachment
type StructuredAgentSessionAttachment,
type StructuredAgentSessionSendControl
} from '../../../src/shared/structured-agent-session-send-mutation'
import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types'
import { MAX_SKILL_REFS } from '../../../src/shared/rpc-contract/structured-agent-session-message-params'
@@ -75,6 +76,8 @@ export function useMobileStructuredSendWithOutcome(args: {
sessionCommands?: readonly AgentSessionSlashCommand[]
onSendError: MobileNativeChatSendErrorReporter
// Older hosts accept neither per-chat model nor effort.
/** The chat's own model and effort each send carries; absent for an older host, which takes neither. */
sendSelection?: () => Pick<StructuredAgentSessionSendControl, 'options' | 'selection'>
sendOptions?: () => Readonly<Record<string, string>> | undefined
// An older host refuses a send body carrying `skills`; there the `$name` text stands alone.
takesSkills?: boolean
@@ -101,6 +104,7 @@ export function useMobileStructuredSendWithOutcome(args: {
sendOptions,
sendsOut,
sessionCommands,
sendSelection,
sessionId,
stateRef,
stopping,
@@ -128,6 +132,7 @@ export function useMobileStructuredSendWithOutcome(args: {
onSendError('Message not sent')
return 'rejected'
}
const captured = sendSelection?.() ?? { options: sendOptions?.() }
const sendAttachments = attachments ?? []
const commandOutcome = await dispatchMobileStructuredCommand({
text,
@@ -195,8 +200,6 @@ export function useMobileStructuredSendWithOutcome(args: {
queuedAt: Date.now(),
...(stopping ? { sentWhileStopping: true as const } : {})
}
// Wait for pending picks so the send carries this chat’s settled options.
const options = sendOptions?.()
const outcome = await sendsOut.trackSend(sendMobileStructuredAgentSessionMessage)({
client,
sessionId,
@@ -207,7 +210,7 @@ export function useMobileStructuredSendWithOutcome(args: {
...(mobileStructuredSendQueues(queueCapable, queueFollowUps, stateRef.current)
? { delivery: 'queue-if-active' as const }
: {}),
...(options ? { options } : {}),
...captured,
deadline,
clientMessageId: send.clientMessageId,
onAccepted: (cursor) =>
@@ -235,6 +238,7 @@ export function useMobileStructuredSendWithOutcome(args: {
sendOptions,
sendsOut,
sessionCommands,
sendSelection,
sessionId,
stateRef,
stopping,
@@ -1,7 +1,7 @@
import { useCallback, useSyncExternalStore } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed'
import { structuredAgentSessionOptionStamp } from '../../../src/shared/structured-agent-session-option-state'
import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed'
const noSubscription = (): (() => void) => () => {}
@@ -17,9 +17,9 @@ export function useMobileStructuredSessionOptionStamp(args: {
(listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()),
[feed]
)
const readStamp = (): string =>
structuredAgentSessionOptionStamp(
feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined
)
const readStamp = (): string => {
const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined
return JSON.stringify([structuredAgentSessionOptionStamp(summary), summary?.hostExecutionPhase])
}
return useSyncExternalStore(subscribe, readStamp, readStamp)
}
@@ -473,7 +473,13 @@ describe('Claude effort default at rest', () => {
const result = await readAtRest(store, record)
expect(result.current).toEqual({ model: 'gpt-5.5', defaulted: [], permissionMode: 'ask' })
expect(result.current).toEqual({
model: 'gpt-5.5',
defaulted: [],
permissionMode: 'ask',
acceptedInputSources: { model: 'saved' }
})
expect(result.current?.effort).toBeUndefined()
})
it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => {
@@ -1,3 +1,11 @@
import { CODEX_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex'
import {
applyStructuredAgentSessionOptions,
createStructuredAgentSessionOptionState
} from '../../shared/structured-agent-session-options'
import { structuredAgentSessionMessageSelection } from '../../shared/structured-agent-session-option-view'
import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options'
import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
import { describe, expect, it, vi } from 'vitest'
import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
import { optionSession } from './codex-structured-option-session.test-fixture'
@@ -329,3 +337,80 @@ describe('Codex captured message choices', () => {
})
})
})
it.each([true, false])(
'sends delayed client literal with accepted effort=%s after another client changes the host tuple',
async (acceptedEffort) => {
const literal = 'retired-literal'
const model = {
id: 'model-b',
label: 'Model B',
isDefault: true,
defaultEffort: 'medium',
efforts: [
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
]
}
const clientA = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex'),
CODEX_SESSION_OPTION_CATALOG,
{
models: [model],
current: {
model: literal,
effort: 'high',
confirmed: ['model', 'effort'],
defaulted: [],
acceptedInputSources: {
model: 'saved',
...(acceptedEffort ? { effort: 'saved' as const } : {})
}
}
}
)
const request = vi.fn(async (method: string, _params?: Record<string, unknown>) =>
method === 'turn/start' ? { turn: { id: 'literal-turn' } } : { config: {} }
)
const session = optionSession(request)
seedMessageDefaults(session)
session.options = new Map([
['model', model.id],
['effort', 'high']
])
session.reportedOptions = { model: model.id, effort: 'high' }
session.appliedTurnOptions = { model: model.id, effort: 'high' }
const expected = { model: literal, ...(acceptedEffort ? { effort: 'high' } : {}) }
const captured = captureStructuredExecutionOptions(
{
agents: claudeAndCodexAgents(),
agent: 'codex',
persistedOptions: { model: model.id, effort: 'high' }
},
undefined,
structuredAgentSessionMessageSelection(clientA)
)
if ('refusal' in captured) {
throw new Error('accepted snapshot was refused')
}
expect(captured.options).toEqual(expected)
const turnOptions = await resolvedCodexMessageOptions(session, captured.options)
expect(turnOptions).toEqual(expected)
expect(Object.fromEntries(session.options)).toEqual({ model: model.id, effort: 'high' })
await startCodexTurn(session, {
clientMessageId: 'delayed-client',
body: USER_MESSAGE,
turnOptions
})
const turn = request.mock.calls.filter(([method]) => method === 'turn/start')
expect(turn).toHaveLength(1)
expect(turn[0]?.[1]).toMatchObject({
threadId: 'thread-1',
clientUserMessageId: 'delayed-client',
...expected
})
if (!acceptedEffort) {
expect(turn[0]?.[1]).not.toHaveProperty('effort')
}
}
)
@@ -120,6 +120,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
afterEach(() => {
vi.restoreAllMocks()
resetRuntimeEnvironmentStatusOwners()
vi.unstubAllGlobals()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -160,6 +160,11 @@ it('steers inherited applied inputs and sends an explicit effort or tier change
await eventually(() => expect(calls('turn/steer')).toHaveLength(2))
const tier = await send({ model: 'model-a', serviceTier: 'flex' })
await expectHeld(tier, 3, 2)
expect(
(await host.journalSnapshot(SESSION)).submissions.find(
(entry) => entry.clientMessageId === tier
)
).toMatchObject({ options: { serviceTier: 'flex' } })
codex.routes['turn/start'] = openTurn('turn-flex')
await finish('turn-high')
await eventually(() => expect(calls('turn/start')).toHaveLength(4))
@@ -1,8 +1,8 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
import { isAgentSessionCreateAdmissionRefusal } from '../../../shared/agent-session-definitive-refusal'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create'
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
import {
refuse,
type AgentSessionAttachResult,
@@ -31,10 +31,10 @@ import { observedReceipt } from './structured-agent-session-operation-settlement
import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration'
import { readAgentSessionHydrationPage } from './agent-session-history-page'
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options'
import { openingMessageOptions } from './structured-agent-session-execution-options'
export type StructuredAgentSessionCreateOptions = {
firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem }
firstMessage?: StructuredAgentSessionFirstMessage
hostLaunchDirectory?: string
catalogModels?: Parameters<typeof reserveRequestFor>[0]['models']
/** Publish after durable creation, before agent delivery starts. */
@@ -212,14 +212,12 @@ async function foundConversation(
},
{
...first,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId,
fields: { body: first.body }
}),
payloadFingerprint: agentSessionSendBodyFingerprint(sessionId, first.body),
origin: 'client',
source: { kind: 'user' },
...openingMessageOptions(candidate.record.options)
...(first.selection === undefined
? openingMessageOptions(candidate.record.options)
: {})
}
)
if (!sent.ok) {
@@ -297,11 +295,3 @@ export function structuredAgentSessionEntryDelegates(
) => createStructuredAgentSession(context(), caller.callerKey, params, options)
}
}
/** Opening messages retain creation-time model/effort choices like later sends. */
function openingMessageOptions(options: Readonly<Record<string, string>> | undefined): {
options?: AgentSessionExecutionOptions
} {
const { model, effort } = options ?? {}
return model ? { options: { model, ...(effort ? { effort } : {}) } } : {}
}
@@ -9,16 +9,23 @@ import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent
/** Capture send-specific choices or saved defaults once under the acceptance lane. */
export function captureStructuredExecutionOptions(
ctx: Pick<AgentSessionTurnContext, 'agents' | 'agent' | 'persistedOptions'>,
supplied?: Readonly<Record<string, string>>
supplied?: Readonly<Record<string, string>>,
selection?: Readonly<Record<string, string>>
): { options: AgentSessionExecutionOptions } | { refusal: AgentSessionWireRefusal } {
const saved = ctx.persistedOptions ?? {}
const candidate = {
...(saved.model ? { model: saved.model } : {}),
...(saved.effort ? { effort: saved.effort } : {}),
...(saved.personality ? { personality: saved.personality } : {}),
...(saved.fastMode ? { fastMode: saved.fastMode } : {}),
...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}),
...supplied
const candidate: Record<string, string> =
selection === undefined
? { ...saved, ...supplied }
: {
...(saved.personality ? { personality: saved.personality } : {}),
...selection
}
// A missing Fast pick has a definite baseline on hosts that offer it.
if (
candidate.fastMode === undefined &&
ctx.agents.definition(ctx.agent)?.restingOptions.acceptsKey('fastMode')
) {
candidate.fastMode = 'false'
}
const options = readAgentSessionExecutionOptions(candidate)
if (!options) {
@@ -39,3 +46,12 @@ export function captureStructuredExecutionOptions(
)
return refusal ? { refusal } : { options }
}
/** The model and effort the chat was created with, which its opening message runs on as a send's
* carried ones do. */
export function openingMessageOptions(options: Readonly<Record<string, string>> | undefined): {
options?: AgentSessionExecutionOptions
} {
const { model, effort } = options ?? {}
return model ? { options: { model, ...(effort ? { effort } : {}) } } : {}
}
@@ -67,6 +67,7 @@ export function sendStructuredAgentSessionTurn(
delivery?: 'queue-if-active'
queuedImageCards?: boolean
options?: Readonly<Record<string, string>>
selection?: Readonly<Record<string, string>>
usageLimitContinuationOfTurnItemId?: string
/** Client send RPCs set origin=client for recovery; host mail/continuations/launch sends omit it. */
userSend?: true
@@ -290,7 +290,8 @@ it('carries Claude effort validation through the SDK queue and saved desktop/pho
expect(nativeWrite.mock.calls.map(([flags]) => flags)).toEqual([
{ effortLevel: 'medium' },
{ effortLevel: 'low' },
{ effortLevel: 'high' }
{ effortLevel: 'high' },
{ fastMode: false }
])
expect(prompts).toHaveLength(1)
expect(prompts[0]?.message.content).toEqual([{ type: 'text', text: 'valid effort' }])
@@ -152,6 +152,8 @@ export function sendPlan(
...(source ? { source } : {}),
clientMessageId,
...(params.options ? { options: params.options } : {}),
...(params.selection !== undefined ? { selection: params.selection } : {}),
// Body-only, so the reducer's echo aliasing never sees control fields or the sender.
payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body),
body: params.body
})
@@ -0,0 +1,239 @@
import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex'
import {
structuredAgentSessionMessageSelection,
structuredAgentSessionOptionView
} from '../../../shared/structured-agent-session-option-view'
import { claudeOpened } from '../../claude/claude-structured-startup-settled-test-support'
import type { ClaudeStructuredSessionEvent } from '../../claude/structured/claude-structured-session-state'
import {
acquired,
fakeClaude,
claudeFrame,
USER_MESSAGE
} from '../../claude/claude-structured-session-test-support'
import {
applyStructuredAgentSessionOptions,
createStructuredAgentSessionOptionState
} from '../../../shared/structured-agent-session-options'
import { expect, it, vi } from 'vitest'
import {
hostTestRestingOptionsRecord as restingRecord,
HOST_TEST_RESTING_OPTIONS_CREATED_LINKS as created,
hostTestRestingOptionsJournal as restingJournal,
hostTestOptionsReadStore
} from './structured-agent-session-host-test-data'
import type { AgentSessionModelOption } from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read'
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
import {
claudeAndCodexAgents,
NO_STRUCTURED_AGENTS
} from './structured-agent-session-adapter-router-test-support'
const SESSION = 'session-1'
it('projects held Claude reports through the full reader while actual controls and Send proceed', async () => {
const events: ClaudeStructuredSessionEvent[] = []
const native = fakeClaude()
const adapter = await acquired(native, {}, events)
await claudeOpened(adapter, SESSION)
const connection = native.connections[0]!
const pending = Promise.withResolvers<unknown>()
const settings = vi.spyOn(connection, 'getSettings').mockReturnValueOnce(pending.promise)
const queue = new StructuredAgentSessionTaskQueue()
const record = { ...restingRecord(), provider: 'claude', options: {} }
const live = { child: { fence: 7 }, params: { provider: 'claude' }, journal: restingJournal() }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the full reader uses only these context members; provider operations use the actual adapter.
const context = {
deps: {
adapter,
agents: claudeAndCodexAgents(),
store: hostTestOptionsReadStore(() => record)
},
serialize: (id: string, task: () => Promise<unknown>) => queue.serialize(id, task),
openConversation: async () => live,
conversation: async () => live
} as unknown as StructuredAgentSessionMutationContext
const read = () =>
readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })
try {
claudeFrame(connection, { type: 'result', subtype: 'success', result: 'done', is_error: false })
await vi.waitFor(() => expect(settings).toHaveBeenCalledOnce())
expect((await read()).current.effort).toBe('high')
await queue.serialize(SESSION, () =>
adapter.dispatch({
sessionId: SESSION,
fence: 7,
clientMessageId: 'while-reporting',
body: USER_MESSAGE
})
)
expect(connection.sent.length).toBeGreaterThan(0)
await queue.serialize(SESSION, () =>
adapter.setOption({ sessionId: SESSION, fence: 7, key: 'model', value: 'claude-opus-5' })
)
pending.resolve({
applied: { model: 'old', effort: 'max' },
effective: { effortLevel: 'max', fastMode: true }
})
await Promise.resolve()
await Promise.resolve()
expect((await read()).current).toMatchObject({ model: 'claude-opus-5', effort: 'high' })
settings.mockResolvedValueOnce({
applied: { model: 'claude-opus-5', effort: 'low' },
effective: { effortLevel: 'low', fastMode: true }
})
claudeFrame(connection, {
type: 'result',
subtype: 'success',
result: 'later',
is_error: false
})
await vi.waitFor(async () =>
expect((await read()).current).toMatchObject({ effort: 'low', fastMode: false })
)
expect(
events.some(
(event) => event.type === 'options-reported' && event.reportedOptions.effort === 'max'
)
).toBe(false)
} finally {
pending.resolve(null)
await adapter.closeSession(SESSION)
}
})
it('uses Off for unpicked Claude Fast at rest and over a native On report, preserving explicit true and false', async () => {
const record = {
...restingRecord(),
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/homes/a' }
}
const live = vi.fn(async () => ({ models: [], current: { model: 'opus', fastMode: true } }))
const session = {
params: { provider: 'claude' },
child: { phase: 'starting', fence: 1 },
journal: restingJournal()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies all fields the effective options reader uses.
const context = {
deps: {
adapter: { readOptions: live },
agents: claudeAndCodexAgents(),
store: hostTestOptionsReadStore(() => record)
},
serialize: (_id: string, task: () => Promise<unknown>) => task(),
openConversation: async () => session,
conversation: async () => session
} as unknown as StructuredAgentSessionMutationContext
const starting = await readStructuredAgentSessionOptions(context, SESSION, {
readsWithoutModel: true
})
expect(starting.current.fastMode).toBe(false)
expect(live).not.toHaveBeenCalled()
expect(starting.current.acceptedInputSources).toEqual({ fastMode: 'policy' })
session.child.phase = 'ready'
expect(
(await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })).current
.fastMode
).toBe(false)
for (const fastMode of ['true', 'false']) {
record.options = { fastMode }
const answer = await readStructuredAgentSessionOptions(context, SESSION, {
readsWithoutModel: true
})
expect(answer.current.acceptedInputSources).toEqual({ fastMode: 'saved' })
expect(answer.current.fastMode).toBe(fastMode === 'true')
}
})
it.each([false, true])(
'captures accepted origins through host read and shared view, resumed=%s',
async (resumed) => {
const record = restingRecord()
if (resumed) {
record.providerHandleChain = [{ ...created[0]!, origin: 'adopted' }]
}
const models: AgentSessionModelOption[] = [
{
id: 'opus[1m]',
label: 'Alias',
isDefault: false,
efforts: [{ value: 'high', label: 'High' }],
serviceTiers: [{ value: 'priority', label: 'Fast' }]
},
{
id: 'other',
label: 'Other',
isDefault: false,
efforts: [],
serviceTiers: [{ value: 'flex', label: 'Flex' }]
}
]
const session = {
child: { fence: 1 },
params: { provider: 'codex' },
journal: restingJournal()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the fields used by the options read.
const context = {
deps: {
adapter: {
readOptions: async () => ({
models,
current: { model: 'opus[1m]', effort: 'high', confirmed: ['model', 'effort'] }
})
},
modelCatalog: { read: async () => ({ origin: 'probe', fetchedAt: 1, models }) },
agents: NO_STRUCTURED_AGENTS,
store: hostTestOptionsReadStore(() => record)
},
serialize: (_id: string, task: () => Promise<unknown>) => task(),
openConversation: async () => session,
conversation: async () => session
} as unknown as StructuredAgentSessionMutationContext
const capture = async () => {
const answer = await readStructuredAgentSessionOptions(context, SESSION, {
readsWithoutModel: true
})
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex'),
CODEX_SESSION_OPTION_CATALOG,
answer,
{ newLaunch: !resumed }
)
return {
answer,
selection: structuredAgentSessionMessageSelection(
structuredAgentSessionOptionView(state, undefined, {})
)
}
}
const initial = await capture()
expect(initial.answer.current.acceptedInputSources).toEqual({})
expect(initial.selection).toEqual({})
for (const model of ['opus[1m]', 'retired-unlisted']) {
record.options = { model, effort: 'high', serviceTier: 'flex' }
const captured = await capture()
expect(captured.answer.current.acceptedInputSources).toEqual({
model: 'saved',
effort: 'saved',
serviceTier: 'saved'
})
expect(captured.selection).toEqual({
model,
effort: 'high',
serviceTier: model === 'opus[1m]' ? 'default' : 'flex'
})
}
}
)
@@ -1,6 +1,9 @@
import { claudeOpened } from '../../claude/claude-structured-startup-settled-test-support'
import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex'
import {
structuredAgentSessionMessageSelection,
structuredAgentSessionSendSelection
} from '../../../shared/structured-agent-session-option-view'
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import type { ClaudeStructuredSessionEvent } from '../../claude/structured/claude-structured-session-state'
import { startCodexTurn } from '../../codex/codex-structured-turn-start'
import { readLiveCodexSessionOptions } from '../../codex/codex-structured-session-options'
import { optionSession } from '../../codex/codex-structured-option-session.test-fixture'
@@ -11,7 +14,6 @@ import { claudeFallbackModelOptions } from '../../claude/claude-structured-sessi
import {
acquired,
fakeClaude,
claudeFrame,
USER_MESSAGE
} from '../../claude/claude-structured-session-test-support'
import { getAgentSessionOptionCatalog } from '../../../shared/agent-session-option-catalog'
@@ -20,7 +22,6 @@ import {
applyStructuredAgentSessionOptions,
createStructuredAgentSessionOptionState
} from '../../../shared/structured-agent-session-options'
import { structuredAgentSessionSendSelection } from '../../../shared/structured-agent-session-option-view'
import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter'
import { describe, expect, it, vi } from 'vitest'
@@ -103,12 +104,17 @@ describe('options at rest', () => {
const current = await readStructuredAgentSessionOptions(context, SESSION, {
readsWithoutModel: true
})
expect(current.current).toEqual({ defaulted: [], permissionMode: 'ask' })
expect(current.current).toMatchObject({
defaulted: [],
permissionMode: 'ask',
acceptedInputSources: {}
})
expect(read).toHaveBeenCalledTimes(2)
const older = await readStructuredAgentSessionOptions(context, SESSION, {
readsWithoutModel: false
})
expect(older.current).toEqual({
expect(older.current).toMatchObject({
acceptedInputSources: { model: 'policy' },
model: 'gpt-account',
defaulted: [],
permissionMode: 'ask'
@@ -157,6 +163,9 @@ describe('options at rest', () => {
expect(resting.listedModelIds).toEqual(['gpt-5.5'])
expect(resting.current).toMatchObject({ model: 'gpt-unlisted', defaulted: [] })
expect(running.current).toMatchObject({ model: 'gpt-unlisted', defaulted: [] })
for (const answer of [resting, running]) {
expect(answer.current.acceptedInputSources).toMatchObject({ model: 'saved', effort: 'saved' })
}
})
it.each(['omp', 'grok', 'opencode'] as const)(
@@ -200,9 +209,10 @@ describe('options at rest', () => {
expect(result.current).not.toHaveProperty('model')
record.options = { model: 'later-model', effort: 'off' }
// Unregistered agents must not report a permission mode.
expect((await read()).current).toEqual({
expect((await read()).current).toMatchObject({
model: 'later-model',
effort: 'off',
acceptedInputSources: { model: 'saved', effort: 'saved' },
defaulted: []
})
for (const emptyStart of [false, true]) {
@@ -304,8 +314,64 @@ describe('a chat at rest, read by the host', () => {
])('%s', async (_name, facts, items, wroteBeforeOpen, defaulted) => {
const read = await readAtRest(facts, items, wroteBeforeOpen)
expect(read.current).toMatchObject({ model: 'gpt-account', defaulted })
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG),
CODEX_SESSION_OPTION_CATALOG,
read,
{ newLaunch: true, coherentCatalog: true }
)
expect(read.current.acceptedInputSources).toEqual(defaulted.length ? {} : { model: 'policy' })
expect(structuredAgentSessionMessageSelection(state)).toEqual(
defaulted.length ? {} : { model: 'gpt-account' }
)
})
it.each([
['unknown', {}, {}, {}],
['known report only', { model: 'gpt-account' }, {}, {}],
[
'unknown with saved literal',
{},
{ model: 'retired-alias', effort: 'high' },
{ model: 'retired-alias', effort: 'high' }
]
] as const)(
'keeps %s live Codex input source-qualified through metadata refresh',
async (_name, applied, saved, selection) => {
const native = optionSession(vi.fn())
native.appliedTurnOptions = applied
const provider = await readLiveCodexSessionOptions(native, undefined)
expect(provider.current.model).toBe('model' in applied ? applied.model : undefined)
const answer = await readAtRest({ options: saved }, [], false, listed, true, provider)
expect(answer.current).toMatchObject({
model: 'model' in saved ? saved.model : 'gpt-account',
defaulted: 'model' in applied || 'model' in saved ? [] : ['model']
})
let state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG),
CODEX_SESSION_OPTION_CATALOG,
answer,
{ newLaunch: true, coherentCatalog: true }
)
expect(answer.current.acceptedInputSources).toEqual(
'model' in saved ? { model: 'saved', effort: 'saved' } : {}
)
expect(structuredAgentSessionMessageSelection(state)).toEqual(selection)
state = applyStructuredAgentSessionModelCatalog(
state,
CODEX_SESSION_OPTION_CATALOG,
{
origin: 'probe',
fetchedAt: 1,
models: listed,
listingNamesConfiguredModel: true
},
{ newLaunch: true, coherentCatalog: true }
)
expect(structuredAgentSessionMessageSelection(state)).toEqual(selection)
}
)
it('preserves sparse Codex knowledge from an actual turn through host and shared desktop/phone values', async () => {
const pending = Promise.withResolvers<unknown>()
const request = vi.fn(() => pending.promise)
@@ -758,74 +824,3 @@ describe('a chat at rest, read by the host', () => {
}
})
})
it('projects held Claude reports through the full reader while actual controls and Send proceed', async () => {
const events: ClaudeStructuredSessionEvent[] = []
const native = fakeClaude()
const adapter = await acquired(native, {}, events)
await claudeOpened(adapter, SESSION)
const connection = native.connections[0]!
const pending = Promise.withResolvers<unknown>()
const settings = vi.spyOn(connection, 'getSettings').mockReturnValueOnce(pending.promise)
const queue = new StructuredAgentSessionTaskQueue()
const record = { ...restingRecord(), provider: 'claude', options: {} }
const live = { child: { fence: 7 }, params: { provider: 'claude' }, journal: restingJournal() }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the full reader uses only these context members; provider operations use the actual adapter.
const context = {
deps: {
adapter,
agents: claudeAndCodexAgents(),
store: hostTestOptionsReadStore(() => record)
},
serialize: (id: string, task: () => Promise<unknown>) => queue.serialize(id, task),
openConversation: async () => live,
conversation: async () => live
} as unknown as StructuredAgentSessionMutationContext
const read = () =>
readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })
try {
claudeFrame(connection, { type: 'result', subtype: 'success', result: 'done', is_error: false })
await vi.waitFor(() => expect(settings).toHaveBeenCalledOnce())
expect((await read()).current.effort).toBe('high')
await queue.serialize(SESSION, () =>
adapter.dispatch({
sessionId: SESSION,
fence: 7,
clientMessageId: 'while-reporting',
body: USER_MESSAGE
})
)
expect(connection.sent.length).toBeGreaterThan(0)
await queue.serialize(SESSION, () =>
adapter.setOption({ sessionId: SESSION, fence: 7, key: 'model', value: 'claude-opus-5' })
)
pending.resolve({
applied: { model: 'old', effort: 'max' },
effective: { effortLevel: 'max', fastMode: true }
})
await Promise.resolve()
await Promise.resolve()
expect((await read()).current).toMatchObject({ model: 'claude-opus-5', effort: 'high' })
settings.mockResolvedValueOnce({
applied: { model: 'claude-opus-5', effort: 'low' },
effective: { effortLevel: 'low', fastMode: true }
})
claudeFrame(connection, {
type: 'result',
subtype: 'success',
result: 'later',
is_error: false
})
await vi.waitFor(async () =>
expect((await read()).current).toMatchObject({ effort: 'low', fastMode: true })
)
expect(
events.some(
(event) => event.type === 'options-reported' && event.reportedOptions.effort === 'max'
)
).toBe(false)
} finally {
pending.resolve(null)
await adapter.closeSession(SESSION)
}
})
@@ -10,6 +10,7 @@ import { structuredAgentSessionThreadGoalReport } from './structured-agent-sessi
// Saved choices, catalog defaults and actual execution reports remain separate facts.
import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire'
import type { AgentSessionAcceptedInputSources } from '../../../shared/agent-session-options-result'
import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure'
import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter'
import {
@@ -101,10 +102,8 @@ export async function readStructuredAgentSessionOptions(
...options.current,
...(model !== undefined ? { model } : {}),
effort: saved.effort ?? options.current.effort,
fastMode:
saved.fastMode !== undefined
? selected.current.fastMode
: (options.current.fastMode ?? selected.current.fastMode),
fastMode: selected.current.fastMode ?? options.current.fastMode,
// The saved tier as the shared speed rule runs it under the saved model.
serviceTier:
saved.serviceTier !== undefined
? selected.current.serviceTier
@@ -115,11 +114,26 @@ export async function readStructuredAgentSessionOptions(
key === 'model' && !options.current.model
? displayDefault !== undefined || selected.current.defaulted?.includes(key) === true
: options.current.defaulted?.includes(key) === true
const defaulted =
(agent !== 'codex' || !live || options.current.model) &&
structuredAgentSessionNeverSent(record, session.journal)
? []
: (['model', 'effort'] as const).filter((key) => saved[key] === undefined && defaultedBy(key))
const neverSent = structuredAgentSessionNeverSent(record, session.journal)
const defaultKeys = (['model', 'effort'] as const).filter(
(key) => saved[key] === undefined && defaultedBy(key)
)
const pinsDefaults = neverSent && (agent !== 'codex' || !live || !!options.current.model)
const defaulted = pinsDefaults ? [] : defaultKeys
const acceptedInputSources: AgentSessionAcceptedInputSources = {}
for (const key of ['model', 'effort', 'fastMode', 'serviceTier'] as const) {
if (current[key] !== undefined && saved[key] !== undefined) {
acceptedInputSources[key] = 'saved'
}
}
for (const key of defaultKeys) {
if (pinsDefaults && current[key] !== undefined) {
acceptedInputSources[key] = 'policy'
}
}
if (saved.fastMode === undefined && selected.current.fastMode === false) {
acceptedInputSources.fastMode = 'policy'
}
const confirmed = options.current.confirmed?.filter((key) =>
key === 'model' || key === 'effort' || key === 'fastMode' || key === 'serviceTier'
? current[key] === options.current[key]
@@ -166,6 +180,7 @@ export async function readStructuredAgentSessionOptions(
: {}),
current: {
...current,
acceptedInputSources,
defaulted,
confirmed,
...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}),
@@ -111,7 +111,8 @@ export async function maybeQueueStructuredAgentSessionSend(
delivery?: 'queue-if-active'
queuedImageCards?: boolean
options?: Readonly<Record<string, string>>
/** Require retained attachments for personal chat sends. */
selection?: Readonly<Record<string, string>>
/** A person's send at a chat surface: every attachment it names must still be stored. */
userSend?: true
personsMessage?: true
}
@@ -153,7 +154,7 @@ export async function maybeQueueStructuredAgentSessionSend(
if (refusal) {
return { ok: false, refusal }
}
const captured = captureStructuredExecutionOptions(ctx, params.options)
const captured = captureStructuredExecutionOptions(ctx, params.options, params.selection)
if ('refusal' in captured) {
return { ok: false, refusal: captured.refusal }
}
@@ -107,7 +107,9 @@ export async function readStructuredAgentSessionOptionsAtRest(
: models
const fastMode =
saved.fastMode === undefined
? null
? rules?.acceptsKey('fastMode')
? false
: null
: decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode)
// An unknown model is one the client already treats as unconfirmed. Only a real listing names the
// account's default; a built-in list's default is a guess, so with none the client keeps its own.
@@ -1,6 +1,11 @@
import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation'
import {
agentSessionMessagePayload,
agentSessionSendBodyFingerprint
} from '../../../shared/structured-agent-session-send-mutation'
import {
createQueuedMessageTestRig,
eventually,
@@ -9,6 +14,8 @@ import {
} from './structured-agent-session-queued-message-rig.test-fixture'
import {
HOST_TEST_SESSION as SESSION,
HOST_TEST_NOW,
HOST_TEST_THREAD as THREAD,
hostTestMessage,
hostTestOperationId
} from './structured-agent-session-host-test-data'
@@ -141,6 +148,111 @@ describe('an opening message created with a model and effort', () => {
})
})
const openingSelections: (Readonly<Record<string, string>> | undefined)[] = [
undefined,
{},
{ model: 'shown-model', effort: 'high', serviceTier: 'default' }
]
it.each(openingSelections)(
'aliases the real opening echo by body and replays the immutable create selection %j',
async (selection) => {
const rest = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } })
try {
const accepted = Promise.withResolvers<ReturnType<typeof acceptedDispatch>>()
rest.adapter.dispatch.mockImplementationOnce(() => accepted.promise)
const saved = { model: 'saved-model', effort: 'low', personality: 'friendly' }
const first: StructuredAgentSessionFirstMessage = {
clientMessageId: hostTestOperationId(),
body: hostTestMessage('opening echo'),
...(selection !== undefined ? { selection } : {})
}
const params = createTestParams(first, { options: saved })
const envelope = structuredClone(params.envelope)
const created = await rest.host.create(CREATE_TEST_CALLER, params, { firstMessage: first })
expect(created.ok).toBe(true)
await vi.waitFor(() => expect(rest.adapter.dispatch).toHaveBeenCalledOnce())
const expected = selection === undefined ? saved : { personality: 'friendly', ...selection }
expect(rest.adapter.dispatch.mock.calls[0][0].executionOptions).toEqual(expected)
const events = rest.adapter.acquire.mock.calls[0][0].events!
events.appendItem(
{ provider: 'codex', threadId: THREAD, turnId: 'root-turn', ordinal: 2 },
first.body,
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await rest.host.flushStreamedEvents(SESSION)
accepted.resolve({
state: 'accepted',
providerIdentity: {
provider: 'codex',
threadId: THREAD,
turnId: 'predicted-turn',
ordinal: 0
}
})
await vi.waitFor(async () =>
expect((await rest.host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe(
'accepted'
)
)
const snapshot = await rest.host.journalSnapshot(SESSION)
expect(
snapshot.items.filter((item) => item.body.kind === 'message' && item.body.role === 'user')
).toHaveLength(1)
expect(snapshot.submissions).toMatchObject([
{
clientMessageId: first.clientMessageId,
payloadFingerprint: agentSessionSendBodyFingerprint(SESSION, first.body),
options: expected
}
])
await rest.store.replaceSessionOptions({
sessionId: SESSION,
fence: rest.store.getRecord(SESSION)!.lease.runtimeFence,
now: HOST_TEST_NOW,
options: { model: 'new-default', effort: 'medium', personality: 'changed' }
})
const changedFirst: StructuredAgentSessionFirstMessage = {
...first,
selection: selection?.model ? {} : { model: 'changed-control' }
}
const changedParams = createTestParams(changedFirst, { options: saved })
expect(changedParams.envelope.payloadFingerprint).not.toBe(envelope.payloadFingerprint)
const conflict = await rest.host.create(
CREATE_TEST_CALLER,
{
...changedParams,
envelope: { ...envelope, payloadFingerprint: changedParams.envelope.payloadFingerprint }
},
{ firstMessage: changedFirst }
)
expect(conflict).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_conflict' }
})
const replay = await rest.host.create(CREATE_TEST_CALLER, params, { firstMessage: first })
expect(replay).toMatchObject({
ok: true,
replayed: true,
value: {
firstMessage: {
clientMessageId: first.clientMessageId,
payloadFingerprint: agentSessionSendBodyFingerprint(SESSION, first.body),
options: expected
}
}
})
expect(params.envelope).toEqual(envelope)
expect(first.selection).toEqual(selection)
expect((await rest.host.journalSnapshot(SESSION)).submissions).toHaveLength(1)
expect(rest.adapter.dispatch).toHaveBeenCalledOnce()
expect(rest.adapter.dispatch.mock.calls[0][0].executionOptions).toEqual(expected)
} finally {
await rest.dispose()
}
}
)
describe('a send carrying its model and effort', () => {
it('fingerprints them with the operation, so the same id with other options conflicts', async () => {
const body = hostTestMessage('once')
@@ -182,3 +294,63 @@ describe('a send kept as a card because Orca stopped before handing it over', ()
}
})
})
it('captures the shown tuple despite unseen saved picks, preserves personality, and distinguishes empty exact from legacy capture', async () => {
const saved = {
model: 'hidden-model',
effort: 'low',
serviceTier: 'priority',
personality: 'friendly'
}
await rig.store.replaceSessionOptions({
sessionId: SESSION,
fence: 1,
now: HOST_TEST_NOW,
options: saved
})
const send = (selection?: Record<string, string>, delivery?: 'queue-if-active') => {
const body = hostTestMessage('shown selection')
const fields = {
body,
...(selection !== undefined ? { selection } : {}),
...(delivery ? { delivery } : {})
}
const params = {
...fields,
envelope: rig.envelope(fields, 'agentSession.send', hostTestOperationId())
}
return { params, result: rig.host.send(CALLER, params) }
}
const shown = { model: 'shown-model', effort: 'high', serviceTier: 'default' }
const exact = send(shown)
expect(await exact.result).toMatchObject({
ok: true,
value: { submission: { options: { ...shown, personality: 'friendly' } } }
})
expect(rig.store.getRecord(SESSION)?.options).toEqual(saved)
const empty = send({})
expect(await empty.result).toMatchObject({
ok: true,
value: { submission: { options: { personality: 'friendly' } } }
})
expect((await rig.submission(empty.params.envelope.clientOperationId))?.options).toEqual({
personality: 'friendly'
})
expect(await send().result).toMatchObject({ ok: true, value: { submission: { options: saved } } })
const changed = {
...exact.params,
selection: {},
envelope: {
...exact.params.envelope,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: SESSION,
fields: { body: exact.params.body, selection: {} }
})
}
}
expect(await rig.host.send(CALLER, changed)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_conflict' }
})
})
@@ -28,7 +28,8 @@ export function mintStructuredAgentSessionStartupAttempt(input: {
attemptId?: string
}): StructuredAgentSessionStartupAttempt {
const { record } = input
const options = input.startupOptions ?? record.options
const selected = input.startupOptions ?? record.options
const options = record.provider === 'claude' ? { fastMode: 'false', ...selected } : selected
return {
attemptId: input.attemptId ?? randomUUID(),
identity: input.identity,
@@ -30,6 +30,9 @@ export function structuredStatusSummariesEqual(
a.runtimePermission?.fence === b.runtimePermission?.fence &&
a.runtimePermission?.mode === b.runtimePermission?.mode &&
(a.runtimePermission === undefined) === (b.runtimePermission === undefined) &&
a.effort === b.effort &&
a.fastMode === b.fastMode &&
a.serviceTier === b.serviceTier &&
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
a.lastAssistantMessage === b.lastAssistantMessage &&
@@ -53,6 +53,10 @@ export function structuredAgentSessionStatusSummary({
// Acknowledged record options remain authoritative across model switches.
const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH)
const permissionMode = record?.permissionMode
// Published so another device's pick moves the client's option stamp.
const effort = record?.options?.effort
const fastMode = record?.options?.fastMode
const serviceTier = record?.options?.serviceTier
const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined
return {
sessionId,
@@ -74,6 +78,9 @@ export function structuredAgentSessionStatusSummary({
: {}),
...(model ? { model } : {}),
...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}),
...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}),
...(serviceTier ? { serviceTier } : {}),
...childWork,
...(providerSession ? { providerSession } : {}),
...(launchDirectory ? { launchDirectory } : {}),
@@ -93,6 +93,7 @@ export async function performSend(
/** Persist only source kind in the submission. */
source?: AgentSessionMessageSource
options?: Readonly<Record<string, string>>
selection?: Readonly<Record<string, string>>
}
): Promise<TurnOutcome<AgentSessionSendResult>> {
const existing = ctx.journal
@@ -110,7 +111,7 @@ export async function performSend(
value: { clientMessageId: input.clientMessageId, submission: { ...existing } }
}
}
const captured = captureStructuredExecutionOptions(ctx, input.options)
const captured = captureStructuredExecutionOptions(ctx, input.options, input.selection)
if ('refusal' in captured) {
return { ok: false, refusal: captured.refusal }
}
@@ -287,6 +287,13 @@ async function newChatFrames(
return { atRest, first: firstPills, settled: pills(settled) }
}
const AT_REST_METADATA = {
confirmed: undefined,
fastMode: undefined,
serviceTier: undefined,
reportedPermissionMode: undefined
}
describe("a new chat's effort pill before its agent starts", () => {
it.each([
['codex', 'gpt-5.5', 'medium'],
@@ -300,7 +307,14 @@ describe("a new chat's effort pill before its agent starts", () => {
expect(first.model?.value).toBe(model)
expect(first.effort?.value).toBe(effort)
expect(settled).toEqual(first)
expect(atRest.current).toEqual({ model, effort, defaulted: [], ...modeOf(agent) })
expect(atRest.current).toStrictEqual({
model,
effort,
defaulted: [],
...modeOf(agent),
...AT_REST_METADATA,
acceptedInputSources: { model: 'policy', effort: 'policy' }
})
}
)
@@ -314,7 +328,13 @@ describe("a new chat's effort pill before its agent starts", () => {
expect(first.model?.value).toBeUndefined()
expect(first.effort).toBeNull()
expect(settled).toEqual(first)
expect(atRest.current).toEqual({ defaulted: [], ...modeOf(agent) })
expect(atRest.current).toStrictEqual({
defaulted: [],
...modeOf(agent),
effort: undefined,
...AT_REST_METADATA,
acceptedInputSources: {}
})
}
)
@@ -324,11 +344,13 @@ describe("a new chat's effort pill before its agent starts", () => {
readsWithoutModel: false
})
expect(atRest.current).toEqual({
expect(atRest.current).toStrictEqual({
model: 'gpt-5.5',
effort: 'high',
defaulted: [],
permissionMode: 'ask'
permissionMode: 'ask',
...AT_REST_METADATA,
acceptedInputSources: { model: 'policy', effort: 'policy' }
})
expect(atRest.conversationCommands).toEqual(['clear', 'compact'])
})
@@ -336,22 +358,26 @@ describe("a new chat's effort pill before its agent starts", () => {
it('names the folder’s default effort for a client that needs a model too', async () => {
const { atRest } = await newChatFrames('codex', { readsWithoutModel: false })
expect(atRest.current).toEqual({
expect(atRest.current).toStrictEqual({
model: 'gpt-5.5',
effort: 'medium',
defaulted: [],
permissionMode: 'ask'
permissionMode: 'ask',
...AT_REST_METADATA,
acceptedInputSources: { model: 'policy', effort: 'policy' }
})
})
it('keeps a saved effort over the default model’s', async () => {
const { atRest, settled } = await newChatFrames('grok', { saved: { effort: 'high' } })
expect(atRest.current).toEqual({
expect(atRest.current).toStrictEqual({
model: 'grok-4',
effort: 'high',
defaulted: [],
...modeOf('grok')
...modeOf('grok'),
...AT_REST_METADATA,
acceptedInputSources: { model: 'policy', effort: 'saved' }
})
expect(settled.effort?.value).toBe('high')
})
@@ -359,6 +385,13 @@ describe("a new chat's effort pill before its agent starts", () => {
it('leaves the effort of a saved model to the agent, as its running child reports it', async () => {
const { atRest } = await newChatFrames('codex', { saved: { model: 'gpt-5.5-mini' } })
expect(atRest.current).toEqual({ model: 'gpt-5.5-mini', defaulted: [], permissionMode: 'ask' })
expect(atRest.current).toStrictEqual({
model: 'gpt-5.5-mini',
defaulted: [],
permissionMode: 'ask',
effort: undefined,
...AT_REST_METADATA,
acceptedInputSources: { model: 'saved' }
})
})
})
@@ -0,0 +1,120 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
const rpc = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: rpc.call,
subscribeStructuredAgentSessionStatus: async () => ({ unsubscribe: () => {} })
}))
vi.mock('@/runtime/structured-agent-session-host-capability', () => ({
useStructuredAgentSessionHostCapability: () => true,
useStructuredAgentSessionOwnPermissionState: () => 'supported',
structuredAgentSessionHostKey: () => 'local'
}))
vi.mock('./native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: () => Promise.resolve()
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string | number>) =>
Object.entries(values ?? {}).reduce(
(text, [key, value]) => text.replaceAll(`{{${key}}}`, String(value)),
fallback
)
}))
import { TooltipProvider } from '@/components/ui/tooltip'
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options'
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
const target = { kind: 'local' } as const
const sessionId = 'mid-turn-picker'
afterEach(() => {
cleanup()
forgetStructuredSessionSelection(sessionId)
})
it('picks effort during a running turn, saves it to the chat, and captures it only for the next message', async () => {
let effort = 'high'
const sent: unknown[] = []
const answer = () => ({
models: [
{
id: 'opus',
label: 'Opus',
efforts: ['low', 'high'].map((value) => ({ value, label: value }))
}
],
current: { model: 'opus', effort, acceptedInputSources: { model: 'saved', effort: 'saved' } }
})
rpc.call.mockImplementation(async (_target, method, input) => {
if (method === 'agentSession.options') {
return answer()
}
if (method === 'agentSession.modelCatalog') {
return { origin: 'unknown' }
}
if (method === 'agentSession.setOption') {
effort = input.value
return { ok: true, value: { ...input, options: { model: 'opus', effort } } }
}
throw new Error(`Unexpected RPC ${method}`)
})
function Composer({ working = true }: { working?: boolean }) {
const options = useStructuredAgentSessionOptions({
agent: 'claude',
sessionId,
target,
fence: 1,
transportEnabled: true,
isVisible: true,
providerVisible: true,
providerRunning: true,
turnId: working ? 'running-turn' : null,
unloadedTurnRevisions: undefined,
mutate: async () => null
})
const capture = useStructuredAgentSessionSendOptions(sessionId, undefined, options.sendSource)
return (
<TooltipProvider>
<NativeChatSessionOptionPickers
surface={options.optionSurface}
snapshot={options.optionSnapshot}
isWorking={working}
/>
<button onClick={() => sent.push(capture())}>Send next message</button>
</TooltipProvider>
)
}
const { rerender } = render(<Composer />)
await waitFor(() =>
expect(screen.getByRole('button', { name: /Model Opus/ }).textContent).toContain('High')
)
fireEvent.click(screen.getByRole('button', { name: 'Send next message' }))
const running = sent[0]
const trigger = screen.getByRole('button', { name: /Model Opus/ })
expect(trigger).toHaveProperty('disabled', false)
fireEvent.click(trigger)
fireEvent.click(await screen.findByRole('radio', { name: 'Low' }))
await waitFor(() =>
expect(
rpc.call.mock.calls.some(
([, method, input]) =>
method === 'agentSession.setOption' && input.key === 'effort' && input.value === 'low'
)
).toBe(true)
)
await waitFor(() =>
expect(screen.getByRole('button', { name: /Model Opus/ }).textContent).toContain('Low')
)
rerender(<Composer working={false} />)
fireEvent.click(screen.getByRole('button', { name: 'Send next message' }))
expect(sent[1]).toEqual({ selection: { model: 'opus', effort: 'low' } })
expect(running).toEqual({ selection: { model: 'opus', effort: 'high' } })
expect(
rpc.call.mock.calls.every(([, method]) => !['turn/steer', 'turn/start'].includes(method))
).toBe(true)
})
@@ -287,7 +287,7 @@ describe('NativeChatSessionOptionPickers', () => {
expect(pill('Model Opus 4.8, Session options Fast').textContent).toBe('Opus 4.8Fast')
})
it('disables the pill while the agent is working', () => {
it('keeps terminal command pickers disabled while the agent is working', () => {
render(
<NativeChatSessionOptionPickers surface={surface} snapshot={[model(), effort]} isWorking />
)
@@ -205,7 +205,7 @@ function SessionOptionPopover(props: {
? `${modelName}, ${pillAccessibleName(optionsTitle, optionsLabel)}`
: modelName
}
disabled={props.isWorking}
disabled={props.isWorking && model.transport !== 'agent-session'}
disabledReason={modelReason ?? optionsReason}
dispatched={
sessionOptionDispatchUnconfirmed(model) || options.some(sessionOptionDispatchUnconfirmed)
@@ -5,9 +5,10 @@ import type { RuntimeClientTarget } from '../../../../shared/runtime-client-targ
/** Apply deltas at the owning runtime so desktop/mobile choices cannot replace each other’s records. */
export function enqueueSessionOptionSettingsWrite(
target: RuntimeClientTarget,
mutation: NativeChatSessionOptionSettingsMutation
mutation: NativeChatSessionOptionSettingsMutation,
options: { expectedEnvironmentPairingRevision?: number } = {}
): Promise<void> {
return callRuntimeRpc(target, 'settings.mutateNativeChatSessionOptions', mutation)
return callRuntimeRpc(target, 'settings.mutateNativeChatSessionOptions', mutation, options)
.then(() => undefined)
.catch(() => undefined)
}
@@ -2,7 +2,11 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createStructuredSessionSelectionController } from '../../../../shared/structured-agent-session-selection-operations'
import { useStructuredSessionSelectionReads } from './use-structured-session-selection-reads'
import { useStructuredAgentSessionOptionStamp } from './structured-agent-session-status-hooks'
import { useAppStore } from '@/store'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import type { AppState } from '@/store/types'
import type { NativeChatSkillDiscovery } from './use-native-chat-skills'
import {
@@ -609,3 +613,254 @@ describe('native chat store subscription performance', () => {
console.info('native-chat-dense-subscription-work', JSON.stringify(results))
})
})
it('bounds the actual pick/read/overlay path at 10 and 100 delayed writes without whole-drain joins', async () => {
const measurements: {
count: number
sends: number
renders: number
visits: number
memberships: number
}[] = []
const target = { kind: 'local' } as const
for (const count of [10, 100]) {
const controller = createStructuredSessionSelectionController(vi.fn())
const replies = Array.from({ length: count }, () =>
Promise.withResolvers<{
kind: 'accepted'
options: Record<string, string>
}>()
)
const reread = vi.fn()
const capture = vi.spyOn(controller, 'captureRead')
let renders = 0
let visits = 0
let sends = 0
let memberships = 0
const all = Promise.all.bind(Promise)
const join = vi.spyOn(Promise, 'all').mockImplementation((values) => {
const entries = Array.from(values)
memberships += entries.length
return all(entries)
})
function Probe() {
useStructuredSessionSelectionReads({
controller,
target,
authority: 'local',
hostOptionStamp: '',
turnId: null,
rereadOptions: reread
})
controller.overlay()
controller.isPending('model')
controller.pendingId()
renders++
return null
}
const view = render(<Probe />)
const baseline = renders
for (let index = 0; index < count; index++) {
act(() => {
const operation = controller.issue('model', index % 2 ? 'sonnet' : 'opus', null)
for (const key of ['identity', 'key', 'transmitted', 'outcome'] as const) {
let value = operation[key]
Object.defineProperty(operation, key, {
configurable: true,
get() {
visits++
return value
},
set(next) {
value = next
}
})
}
controller.flush(() => {
sends++
return replies[index].promise
})
})
}
join.mockRestore()
expect(sends).toBe(count)
expect(renders - baseline).toBe(count)
expect(memberships).toBe(0)
expect(visits).toBeLessThanOrEqual(40 * count)
expect(capture).not.toHaveBeenCalled()
expect(reread).not.toHaveBeenCalled()
measurements.push({ count, sends, renders: renders - baseline, visits, memberships })
await act(async () =>
replies[count - 1].resolve({ kind: 'accepted', options: { model: 'sonnet' } })
)
expect(reread).toHaveBeenCalledTimes(1)
const captured = controller.captureRead()
await act(async () => replies[1].resolve({ kind: 'accepted', options: { model: 'sonnet' } }))
expect(reread).toHaveBeenCalledTimes(2)
act(() => controller.retire(captured))
expect(controller.captureRead()).toEqual([2])
expect(controller.overlay()).toEqual({})
const draining = controller.drain()
act(() => controller.abandon())
await draining
for (const reply of replies) {
reply.resolve({ kind: 'accepted', options: { model: 'opus' } })
}
await act(async () => {
await Promise.resolve()
})
expect(controller.overlay()).toEqual({})
view.unmount()
}
expect(measurements[1].visits).toBeLessThanOrEqual(measurements[0].visits * 11)
console.info('selection-pick-upstream-work', JSON.stringify(measurements))
})
it('keeps unrelated store, same-host status and other-host consumers quiet at N and 10N', async () => {
for (const count of [10, 100]) {
const emits = new Map<string, (event: AgentSessionStatusEvent) => void>()
handOffTransport.subscribe.mockImplementation(async (target, emit) => {
emits.set(target.kind === 'local' ? 'local' : target.environmentId, emit)
return { unsubscribe: vi.fn() }
})
const target = { kind: 'local' } as const
const feed = getStructuredAgentSessionStatusFeed(target)
const snapshot = vi.spyOn(feed, 'getSnapshot')
const other = getStructuredAgentSessionStatusFeed({
kind: 'environment',
environmentId: 'other'
})
const controllers = Array.from({ length: count }, () =>
createStructuredSessionSelectionController(vi.fn())
)
const renders = Array.from({ length: count }, () => 0)
const rereads = controllers.map(() => vi.fn())
function Probe({ index }: { index: number }) {
const stamp = useStructuredAgentSessionOptionStamp(`session-${index}`, target)
useStructuredSessionSelectionReads({
controller: controllers[index],
target,
authority: 'local',
hostOptionStamp: stamp,
turnId: null,
rereadOptions: rereads[index]
})
renders[index]++
return null
}
const view = render(controllers.map((_, index) => <Probe key={index} index={index} />))
let stop = () => {}
await act(async () => {
stop = other.activate()
})
const baseline = [...renders]
const publish = (host: string, sessionId: string, model: string) => {
const emit = emits.get(host)
if (!emit) {
throw new Error('Missing test status transport')
}
act(() =>
emit({
type: 'snapshot',
sessions: [
{
sessionId,
workspaceId: 'wt-1',
agent: 'codex',
status: 'idle',
latestPrompt: 'hello',
updatedAt: 1,
effort: model
}
]
})
)
}
snapshot.mockClear()
act(() => useAppStore.setState({ lastTerminalInputAtByPaneKey: { other: 1 } }))
expect(snapshot).not.toHaveBeenCalled()
publish('other', 'session-0', 'other-model')
expect(snapshot).not.toHaveBeenCalled()
publish('local', 'unmounted', 'unrelated-model')
const upstreamReads = snapshot.mock.calls.length
expect(upstreamReads).toBeGreaterThanOrEqual(count)
expect(upstreamReads).toBeLessThanOrEqual(count * 3)
expect(renders).toEqual(baseline)
publish('local', 'session-0', 'sonnet')
expect(renders).toEqual(baseline.map((value, index) => value + (index === 0 ? 1 : 0)))
expect(rereads[0]).toHaveBeenCalledTimes(1)
const beforePick = [...renders]
act(() => controllers[1].issue('model', 'opus', null))
expect(renders).toEqual(beforePick.map((value, index) => value + (index === 1 ? 1 : 0)))
console.info(
'selection-status-upstream-work',
JSON.stringify({ count, upstreamReads, unrelatedRenders: 0 })
)
view.unmount()
stop()
snapshot.mockRestore()
resetStructuredAgentSessionStatusFeedsForTests()
}
})
it('bounds selected continuity wakes and keeps other-host consumers quiet at N and 10N', () => {
for (const count of [10, 100]) {
const targets = Array.from({ length: count }, (_, index) => ({
kind: 'environment' as const,
environmentId: index % 2 ? 'continuity-other' : 'continuity-selected'
}))
const controllers = targets.map(() => createStructuredSessionSelectionController(vi.fn()))
const reads = targets.map(() => vi.fn())
const renders = targets.map(() => 0)
function Probe({ index }: { index: number }) {
useStructuredSessionSelectionReads({
controller: controllers[index],
target: targets[index],
authority: '',
hostOptionStamp: '',
turnId: null,
rereadOptions: reads[index]
})
renders[index]++
return null
}
const environments = targets
.slice(0, 2)
.map((target) => ({ id: target.environmentId, createdAt: 1, pairingRevision: 2 }))
replaceRuntimeEnvironmentRevisions(environments, {
retired: [],
sameHost: [],
pending: [{ id: 'continuity-selected', fromRevision: 1 }]
})
const view = render(targets.map((_, index) => <Probe key={index} index={index} />))
const baseline = [...renders]
const projectionReads = vi.spyOn(useAppStore.getState().runtimeStatusByEnvironmentId, 'get')
act(() => useAppStore.setState({ lastTerminalInputAtByPaneKey: { other: count } }))
expect(projectionReads).not.toHaveBeenCalled()
const publishCatalog = () =>
useAppStore.setState({ runtimeEnvironments: [...useAppStore.getState().runtimeEnvironments] })
act(publishCatalog)
expect(renders).toEqual(baseline)
expect(projectionReads.mock.calls.length).toBeLessThanOrEqual(2 * count)
replaceRuntimeEnvironmentRevisions(environments, {
retired: [],
sameHost: [{ id: 'continuity-selected', fromRevision: 1, toRevision: 2 }]
})
act(publishCatalog)
expect(renders).toEqual(baseline.map((value, index) => value + (index % 2 ? 0 : 1)))
expect(reads.map((read) => read.mock.calls.length)).toEqual(
targets.map((_, index) => (index % 2 ? 0 : 1))
)
console.info(
'selection-continuity-work',
JSON.stringify({
count,
projectionReads: projectionReads.mock.calls.length,
otherHostRenders: 0
})
)
view.unmount()
projectionReads.mockRestore()
}
replaceRuntimeEnvironmentRevisions([])
})
@@ -0,0 +1,22 @@
import type { AgentType } from '../../../../shared/agent-status-types'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog'
import {
applyStructuredAgentSessionModelCatalog,
createStructuredAgentSessionOptionState,
type StructuredAgentSessionOptionState
} from '../../../../shared/structured-agent-session-options'
import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots'
/** The first frame: the host's saved list when the renderer already holds it, else the seed,
* which shows the quiet placeholder until the host answers. */
export function firstFrameOptionState(
agent: AgentType,
target: RuntimeClientTarget,
launch: { newLaunch: boolean; worktree?: string; seedsModel: boolean }
): StructuredAgentSessionOptionState {
const seed = structuredAgentSessionSeedCatalog(agent)
const seeded = createStructuredAgentSessionOptionState(agent, seed)
const snapshot = readHostModelCatalogSnapshot(target, agent, launch)
return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded
}
@@ -1,4 +1,7 @@
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import type {
CaptureStructuredAgentSessionPresentation,
StructuredAgentSessionMutate
} from './use-structured-agent-session-mutate'
import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
@@ -16,6 +19,23 @@ export type StructuredAgentSessionOptionsArgs = {
fence: number | null
turnId: string | null
unloadedTurnRevisions: number | undefined
capturePresentation?: CaptureStructuredAgentSessionPresentation
mutate: StructuredAgentSessionMutate
launch?: StructuredAgentSessionLaunchView
}
import type { AgentSessionConversationCommand } from '../../../../shared/agent-session/agent-session-conversation-command'
import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind'
import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire'
export type StructuredSessionConversationSupport = {
sessionId: string
commands: readonly AgentSessionConversationCommand[]
threadGoal: AgentSessionOptionsResult['threadGoal']
reportedThreadGoal: AgentSessionOptionsResult['reportedThreadGoal']
contextUsage: AgentSessionOptionsResult['contextUsage']
acceptsImages: boolean | undefined
rewind: AgentSessionRewindSupport
/** The fence the read answered for; rewind support is only that runtime's. */
fence: number | null
}
@@ -10,6 +10,7 @@ export type StructuredAgentSessionPendingSend = {
previewUris: readonly string[]
queuedAt: number
delivery?: 'queue-if-active'
selection?: Readonly<Record<string, string>>
options?: Readonly<Record<string, string>>
usageLimitContinuationOfTurnItemId?: string
/** Sending blocks new sends; recorded releases the slot but retains its echo until host rows/cards arrive. */
@@ -133,6 +133,7 @@ export async function attemptStructuredAgentSessionSend(args: {
body: entry.body,
...(entry.delivery ? { delivery: entry.delivery } : {}),
...(entry.options ? { options: entry.options } : {}),
...(entry.selection !== undefined ? { selection: entry.selection } : {}),
...(entry.usageLimitContinuationOfTurnItemId
? { usageLimitContinuationOfTurnItemId: entry.usageLimitContinuationOfTurnItemId }
: {})
@@ -20,6 +20,7 @@ export type StructuredAgentSessionSendInput = {
/** Asks the host to hold it as a card while the agent works; decided once, at the send. */
delivery?: 'queue-if-active'
/** The composer's model and effort for the turn it starts, read once at the send. */
selection?: Readonly<Record<string, string>>
options?: Readonly<Record<string, string>>
/** The caller keeps the text if it comes back, instead of the chat's composer. */
callerKeepsText?: true
@@ -51,7 +52,8 @@ export function takeStructuredAgentSessionSendSlot(
previewUris: attachments.map((attachment) => attachment.previewUri),
queuedAt: input.now ?? Date.now(),
...(input.delivery ? { delivery: input.delivery } : {}),
...(input.options ? { options: input.options } : {}),
...(input.options ? { options: { ...input.options } } : {}),
...(input.selection !== undefined ? { selection: { ...input.selection } } : {}),
...(input.usageLimitContinuationOfTurnItemId
? { usageLimitContinuationOfTurnItemId: input.usageLimitContinuationOfTurnItemId }
: {}),
@@ -1,7 +1,9 @@
import { useAppStore } from '@/store'
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
@@ -60,7 +62,7 @@ function renderOptions(initial: Props = {}) {
turnId: null,
unloadedTurnRevisions: undefined,
mutate,
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' } }
}),
{ initialProps: initial }
)
@@ -116,8 +118,28 @@ function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] {
const flush = (): Promise<void> => act(async () => {})
const initialStore = useAppStore.getState()
afterEach(() => {
cleanup()
forgetStructuredSessionSelection(sessionId, PAIRED_TARGET)
forgetStructuredSessionSelection(`${sessionId}-other`, PAIRED_TARGET)
useAppStore.setState(initialStore, true)
})
describe('host model catalog read', () => {
beforeEach(() => {
useAppStore.getState().setRuntimeEnvironments([
{
id: PAIRED_TARGET.environmentId,
name: 'catalog host',
createdAt: 1,
updatedAt: 1,
pairingRevision: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'loopback'
}
])
mocks.call.mockReset()
resetHostModelCatalogSnapshotsForTests()
sessionCount += 1
@@ -203,6 +225,7 @@ describe('host model catalog read', () => {
)
const { result, unmount } = renderOptions({ attached: true })
await flush()
expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.options')).toBe(true)
live.resolve({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live', confirmed: ['model'] }
@@ -62,7 +62,7 @@ function renderOptions() {
...(providerRunning ? { providerRunning } : {}),
unloadedTurnRevisions: undefined,
mutate,
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' } }
}),
{ initialProps: NO_TURN }
)
@@ -16,16 +16,12 @@ export type StructuredAgentSessionLaunchView = {
/** The encoded selection the launch seeded, shown until the host names the model. */
seedOptions?: Readonly<Record<string, string>>
explicitOptions?: Readonly<Record<string, string>>
/** Picks the launch holds and applies before it publishes. */
heldOptions: Readonly<Record<string, string>>
/** Where the chat runs; its own config may replace the listed default. */
worktree?: string
/** Its paired server has not said what create seeds yet; picks wait for it. */
seedPending?: true
}
const NO_HELD_OPTIONS: Readonly<Record<string, string>> = {}
function useLaunchView(
sessionId: string,
worktreeId: string | null | undefined,
@@ -37,8 +33,8 @@ function useLaunchView(
const current = getStructuredAgentSessionLaunchSelection(sessionId)
if (current) {
snapshot = current
} else if (snapshot && snapshot.held !== NO_HELD_OPTIONS) {
snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS, seedPending: true }
} else if (snapshot && !snapshot.seedPending) {
snapshot = { seed: snapshot.seed, seedPending: true }
}
return snapshot
}
@@ -70,7 +66,6 @@ function useLaunchView(
: null
})
}
const held = selection?.held ?? NO_HELD_OPTIONS
const seedPending = launching && (!selection || selection.seedPending === true)
return useMemo(
() =>
@@ -79,12 +74,11 @@ function useLaunchView(
kind: intent.kind,
...(selection?.seed ? { seedOptions: selection.seed } : {}),
...(seedPending ? { seedPending: true as const } : {}),
heldOptions: held,
...(selection?.explicit ? { explicitOptions: selection.explicit } : {}),
...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {})
}
: undefined,
[held, intent.kind, selection?.seed, selection?.explicit, seedPending, worktreeId]
[intent.kind, selection?.seed, selection?.explicit, seedPending, worktreeId]
)
}
@@ -197,8 +197,8 @@ describe('useStructuredAgentSession context usage', () => {
}
expect(optionReads()).toBe(2)
await act(async () => replies[1](160_000))
// The one in flight lands, and one more read covers every revision behind it.
expect(result.current.contextUsage).toMatchObject({ usedTokens: 160_000 })
// The obsolete read cannot replace the answer; one current successor covers the revisions.
expect(result.current.contextUsage).toMatchObject({ usedTokens: 150_000 })
await waitFor(() => expect(optionReads()).toBe(3))
await act(async () => replies[2](175_000))
expect(result.current.contextUsage).toMatchObject({ usedTokens: 175_000 })
@@ -1,56 +1,46 @@
import type { StructuredSessionConversationSupport } from './structured-agent-session-options-contract'
import {
createStructuredOptionPickBook,
resetStructuredOptionPickBook,
structuredOptionPickIsPending
} from '../../../../shared/structured-agent-session-option-pick-book'
structuredSessionSelectionAuthority,
structuredSessionSelectionController
} from '@/lib/structured-agent-session-selection-controller'
import { useStructuredSessionSelectionReads } from './use-structured-session-selection-reads'
import { projectRuntimePermission } from '../../../../shared/structured-agent-session-permission-option'
import { useStructuredAgentSessionStatusSummary } from './StructuredAgentSessionStatusBridge'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AgentSessionConversationCommand } from '../../../../shared/agent-session/agent-session-conversation-command'
import type {
AgentSessionModelCatalogResult,
AgentSessionOptionsResult
AgentSessionOptionsResult,
AgentSessionModelCatalogResult
} from '../../../../shared/agent-session-wire'
import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog'
import { usesInstalledAgentModelCatalog } from '../../../../shared/agent-model-catalog-ownership'
import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog'
import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities'
import {
applyStructuredAgentSessionModelCatalog,
applyStructuredAgentSessionOptions,
settleStructuredAgentSessionBuiltinCatalog,
createStructuredAgentSessionOptionState,
type StructuredAgentSessionOptionState
} from '../../../../shared/structured-agent-session-options'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view'
import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities'
import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities'
import {
AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY
} from '../../../../shared/agent-session-create-capabilities'
import {
useStructuredAgentSessionHostCapability,
useStructuredAgentSessionOwnPermissionState,
structuredAgentSessionHostKey
} from '@/runtime/structured-agent-session-host-capability'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots'
import { cloneStructuredAgentSessionOptionRecord } from '../../../../shared/structured-agent-session-option-state'
import {
createCoalescedPollRunner,
type CoalescedPollRunner
} from '../right-sidebar/coalesced-poll-runner'
/** Use the cached host list on first paint, otherwise a quiet seed placeholder until the host answers. */
export function firstFrameOptionState(
agent: AgentType,
target: RuntimeClientTarget,
launch: { newLaunch: boolean; worktree?: string; seedsModel: boolean }
): StructuredAgentSessionOptionState {
const seed = structuredAgentSessionSeedCatalog(agent)
const seeded = createStructuredAgentSessionOptionState(agent, seed)
const snapshot = readHostModelCatalogSnapshot(target, agent, launch)
return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded
}
export { firstFrameOptionState } from './structured-agent-session-option-first-frame'
import { firstFrameOptionState } from './structured-agent-session-option-first-frame'
/** The picker's option state for one session: seeded, kept across its fences, re-read each turn. */
export function useStructuredAgentSessionOptionState(args: {
@@ -75,7 +65,6 @@ export function useStructuredAgentSessionOptionState(args: {
const {
agent,
fence,
hostOptionStamp,
identity,
newLaunch,
optionCatalog,
@@ -85,21 +74,11 @@ export function useStructuredAgentSessionOptionState(args: {
readsBeforeStart,
sessionId,
target,
turnId,
worktree
} = args
const { summary, observation } = useStructuredAgentSessionStatusSummary(sessionId, target)
const [conversationSupport, setConversationSupport] = useState<{
sessionId: string
commands: readonly AgentSessionConversationCommand[]
threadGoal: AgentSessionOptionsResult['threadGoal']
reportedThreadGoal: AgentSessionOptionsResult['reportedThreadGoal']
contextUsage: AgentSessionOptionsResult['contextUsage']
acceptsImages: boolean | undefined
rewind: AgentSessionRewindSupport
/** The fence the read answered for; rewind support is only that runtime's. */
fence: number | null
} | null>(null)
const [conversationSupport, setConversationSupport] =
useState<StructuredSessionConversationSupport | null>(null)
// A revision the loaded window dropped can move the host's whole-journal context facts.
const contextRefresh = conversationSupport?.contextUsage ? (args.unloadedTurnRevisions ?? 0) : 0
// Paint cached host evidence immediately; without it keep the placeholder until the host names the model.
@@ -114,11 +93,15 @@ export function useStructuredAgentSessionOptionState(args: {
const firstFrameLaunch = useRef({ target, newLaunch, worktree, seedsModel })
useEffect(() => {
firstFrameLaunch.current = { target, newLaunch, worktree, seedsModel }
}, [target, newLaunch, worktree, seedsModel])
}, [newLaunch, seedsModel, target, worktree])
const optionStateRef = useRef(optionState)
const activeOptionRecordRef = useRef(optionState.record)
// Picks on their way to the host, which every send carries too.
const optionPicks = useRef(createStructuredOptionPickBook())
const authority = structuredSessionSelectionAuthority(target)
const controller = structuredSessionSelectionController(target, sessionId, agent)
const takesSelection = useStructuredAgentSessionHostCapability(
target,
AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY
)
const updateOptionState = useCallback(
(update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => {
const next = update(optionStateRef.current)
@@ -134,41 +117,59 @@ export function useStructuredAgentSessionOptionState(args: {
contextRef.current = contextKey
}, [contextKey])
const isCurrentContext = useCallback(() => contextRef.current === contextKey, [contextKey])
const writes = useRef({ count: 0, readback: false })
const coherentCatalog = usesInstalledAgentModelCatalog(
agent,
useStructuredAgentSessionHostCapability(target, AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY)
)
const optionIdentityRef = useRef(identity)
// A chat this view started names the default it runs until its first send pins it.
const applyOptionsAnswer = useCallback(
(current: StructuredAgentSessionOptionState, result: AgentSessionOptionsResult) =>
optionCatalog
? applyStructuredAgentSessionOptions(current, optionCatalog, result, {
newLaunch,
coherentCatalog
})
: current,
[newLaunch, optionCatalog, coherentCatalog]
)
const host = target.kind === 'local' ? 'local' : target.environmentId
const optionIdentity = JSON.stringify([identity, host])
const optionIdentityRef = useRef({ identity: optionIdentity, controller })
useEffect(() => {
const previous = optionStateRef.current
const sameSession = optionIdentityRef.current === identity
optionIdentityRef.current = identity
const sameSession =
optionIdentityRef.current.identity === optionIdentity &&
optionIdentityRef.current.controller === controller
optionIdentityRef.current = { identity: optionIdentity, controller }
const launch = firstFrameLaunch.current
const seeded = firstFrameOptionState(agent, launch.target, launch)
// Retain saved picks/account listings across same-session restarts until the starting provider can be read.
const kept =
sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null
sameSession &&
(previous.optionsAnswerPresent ||
(previous.catalogSource !== 'seed' && previous.catalogSource !== null))
? {
...previous,
catalogSource: 'host' as const,
record: cloneStructuredAgentSessionOptionRecord(previous.record),
pendingId: null,
reportedPermissionMode: undefined
}
: seeded
const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept
writes.current = { count: 0, readback: false }
resetStructuredOptionPickBook(optionPicks.current)
optionStateRef.current = next
activeOptionRecordRef.current = next.record
setOptionState(next)
}, [agent, contextKey, fence, identity])
}, [agent, fence, optionIdentity, contextKey, authority, controller])
const readEpoch = useRef(0)
const optionsReadRef = useRef<CoalescedPollRunner | null>(null)
// Read each turn and after provider start to confirm the model the host actually runs.
useEffect(() => {
if (!providerVisible || (providerStarting && !readsBeforeStart) || !optionCatalog) {
if (
!providerVisible ||
(providerStarting && !readsBeforeStart && !takesSelection) ||
!optionCatalog
) {
return
}
let stale = false
@@ -176,23 +177,23 @@ export function useStructuredAgentSessionOptionState(args: {
if (!isCurrentContext()) {
return
}
if (writes.current.count) {
writes.current.readback = true
return
}
writes.current.readback = false
const epoch = readEpoch.current
const record = activeOptionRecordRef.current
const readGeneration = optionPicks.current.generation
const result = await callStructuredAgentSession<AgentSessionOptionsResult>(
target,
'agentSession.options',
{ sessionId }
const completed = controller.captureRead()
const result = await controller.request((options) =>
callStructuredAgentSession<AgentSessionOptionsResult>(
target,
'agentSession.options',
{ sessionId },
options
)
)
if (
!stale &&
isCurrentContext() &&
activeOptionRecordRef.current === record &&
optionPicks.current.generation === readGeneration
readEpoch.current === epoch &&
structuredSessionSelectionAuthority(target) === authority &&
activeOptionRecordRef.current === record
) {
setConversationSupport({
sessionId,
@@ -205,13 +206,9 @@ export function useStructuredAgentSessionOptionState(args: {
fence
})
updateOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionOptions(current, optionCatalog, result, {
newLaunch,
coherentCatalog
})
: current
current.record === record ? applyOptionsAnswer(current, result) : current
)
controller.retire(completed)
}
})
optionsReadRef.current = runner
@@ -222,18 +219,18 @@ export function useStructuredAgentSessionOptionState(args: {
optionsReadRef.current = null
}
}, [
newLaunch,
applyOptionsAnswer,
isCurrentContext,
coherentCatalog,
authority,
controller,
takesSelection,
fence,
optionCatalog,
hostOptionStamp,
providerStarting,
providerVisible,
readsBeforeStart,
sessionId,
target,
turnId,
updateOptionState
])
@@ -242,12 +239,24 @@ export function useStructuredAgentSessionOptionState(args: {
useEffect(() => {
if (contextRefresh !== seenContextRefresh.current) {
seenContextRefresh.current = contextRefresh
readEpoch.current += 1
optionsReadRef.current?.run()
}
}, [contextRefresh])
// An options read predating the account listing needs a fresh catalog-aware answer.
const rereadOptions = useCallback(() => optionsReadRef.current?.run(), [])
// An options answer made before the account's listing landed listed none of it.
const rereadOptions = useCallback(() => {
readEpoch.current += 1
optionsReadRef.current?.run()
}, [])
useStructuredSessionSelectionReads({
controller,
target,
authority,
hostOptionStamp: args.hostOptionStamp,
turnId: args.turnId,
rereadOptions
})
const receiveCatalog = (
catalog: AgentSessionModelCatalogResult | null,
_event: 'demand' | 'progress' | 'settled'
@@ -264,23 +273,24 @@ export function useStructuredAgentSessionOptionState(args: {
rereadOptions()
}
}
const beginOptionWrite = (_id: string) => {
const record = activeOptionRecordRef.current
writes.current.count += 1
return (readback: boolean) => {
if (!isCurrentContext() || activeOptionRecordRef.current !== record) {
return
}
writes.current.count -= 1
writes.current.readback ||= readback
if (!writes.current.count && writes.current.readback) {
rereadOptions()
}
}
}
const permissionOwnSupport = useStructuredAgentSessionOwnPermissionState(target)
const permissionOwnSupported =
permissionOwnSupport === 'unknown' ? null : permissionOwnSupport === 'supported'
const optionView = useCallback(
(
state: StructuredAgentSessionOptionState,
seed: Readonly<Record<string, string>> | undefined,
explicit?: Readonly<Record<string, string>>
) => ({
...projectRuntimePermission(
structuredAgentSessionOptionView(state, seed, controller.overlay(), explicit),
summary,
fence
),
permissionOwnSupported
}),
[controller, summary, fence, permissionOwnSupported]
)
const takesSendOptions = useStructuredAgentSessionHostCapability(
target,
AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY
@@ -288,26 +298,17 @@ export function useStructuredAgentSessionOptionState(args: {
return {
// What the picker shows and a send carries.
optionView: (
...args: Parameters<typeof structuredAgentSessionOptionView>
): StructuredAgentSessionOptionState => {
const view = {
...projectRuntimePermission(structuredAgentSessionOptionView(...args), summary, fence),
permissionOwnSupported
}
return structuredOptionPickIsPending(optionPicks.current, 'model')
? { ...view, pendingId: 'model' }
: view
},
optionView,
controller,
takesSelection,
takesSendOptions,
conversationSupport,
rereadOptions,
receiveCatalog,
beginOptionWrite,
isCurrentContext,
optionState,
optionStateRef,
activeOptionRecordRef,
optionPicks,
updateOptionState
}
}
@@ -3,6 +3,7 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HostCapability from '@/runtime/structured-agent-session-host-capability'
import type * as LaunchOptions from '@/lib/structured-agent-session-launch-options'
const mocks = vi.hoisted(() => ({ call: vi.fn(), coherent: false }))
@@ -19,7 +20,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
vi.mock('./native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: vi.fn()
}))
vi.mock('@/lib/structured-agent-session-launch-options', () => ({
vi.mock('@/lib/structured-agent-session-launch-options', async (original) => ({
...(await original<typeof LaunchOptions>()),
holdStructuredAgentSessionLaunchOption: vi.fn(),
getStructuredAgentSessionLaunchSelection: () => null
}))
@@ -116,7 +118,6 @@ function renderNewChat(args: {
mutate: vi.fn(),
launch: {
kind: 'new',
heldOptions: {},
worktree: 'id:wt-1',
...(args.explicitOptions ? { explicitOptions: args.explicitOptions } : {}),
...(args.seedOptions ? { seedOptions: args.seedOptions } : {})
@@ -360,11 +361,13 @@ it('refuses typed literals while the current fence awaits options, then accepts
const pending = deferred<AgentSessionOptionsResult>()
let reads = 0
mocks.call.mockImplementation((_target: unknown, method: string) =>
method === 'agentSession.options'
? ++reads === 1
? Promise.resolve({ models: [], current: {} })
: pending.promise
: Promise.resolve({ origin: 'unknown' })
method === 'agentSession.setOption'
? Promise.resolve({ ok: true, value: { options: { model: 'literal' } } })
: method === 'agentSession.options'
? ++reads === 1
? Promise.resolve({ models: [], current: {} })
: pending.promise
: Promise.resolve({ origin: 'unknown' })
)
const mutate = vi.fn().mockResolvedValue({
status: 'accepted',
@@ -394,10 +397,20 @@ it('refuses typed literals while the current fence awaits options, then accepts
expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(false)
)
expect(mutate).not.toHaveBeenCalled()
expect(mocks.call.mock.calls.filter((call) => call[1] === 'agentSession.setOption')).toEqual([])
await act(async () => pending.resolve({ models: [], current: {} }))
await act(async () =>
expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(true)
)
expect(mutate).toHaveBeenCalledTimes(1)
expect(mocks.call).toHaveBeenCalledWith(
LOCAL_TARGET,
'agentSession.setOption',
{
envelope: expect.objectContaining({ sessionId: 'locked-chat', expectedRuntimeFence: 2 }),
key: 'model',
value: 'literal'
},
{}
)
view.unmount()
})
@@ -85,13 +85,12 @@ function pill(snapshot: readonly SessionOptionDescriptor[]): Pill {
const LOCAL = { kind: 'local' } as const
const NOT_STARTED: { running: { fence: number } | null } = { running: null }
const NO_HELD = {}
const NO_MUTATE: StructuredAgentSessionMutate = vi.fn()
function renderChat(agent: string, sessionId: string, worktree: string) {
const frames: Pill[] = []
// Stable across renders, as the pane's own props are: a new object would re-run every read.
const launch = { kind: 'new' as const, heldOptions: NO_HELD, worktree }
const launch = { kind: 'new' as const, worktree }
const view = renderHook(
({ running }: { running: { fence: number } | null }) => {
const options = useStructuredAgentSessionOptions({
@@ -1,3 +1,8 @@
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
import type {
AgentSessionMutationResult,
AgentSessionOptionResult
} from '../../../../shared/agent-session-wire'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import {
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY,
@@ -7,9 +12,9 @@ import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
const mocks = vi.hoisted(() => ({ call: vi.fn(), write: vi.fn() }))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
@@ -22,22 +27,40 @@ vi.mock('./native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: vi.fn()
}))
vi.mock('@/lib/structured-agent-session-launch-options', () => ({
holdStructuredAgentSessionLaunchOption: vi.fn(),
getStructuredAgentSessionLaunchSelection: () => null
}))
import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots'
const mutate: StructuredAgentSessionMutate = vi.fn(async () => null)
afterEach(() => {
forgetStructuredSessionSelection('session-1', LOCAL_TARGET)
setLocalRuntimeCapabilitiesForTests(null)
})
beforeEach(() => {
mocks.write.mockReset().mockImplementation(async ({ key, value }) => acceptedReply(key, value))
})
const LOCAL_TARGET = { kind: 'local' } as const
function acceptedReply(
key: string,
value: string
): AgentSessionMutationResult<AgentSessionOptionResult> {
return {
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'permission-test', sequence: 1 },
value: { key, value, options: { [key]: value } }
}
}
function optionsAnswering(...modes: (string | undefined)[]): void {
let read = 0
mocks.call.mockImplementation((_target: unknown, method: string) => {
mocks.call.mockImplementation((_target: unknown, method: string, argumentsForWrite: unknown) => {
if (method === 'agentSession.setOption') {
return mocks.write(argumentsForWrite)
}
if (method !== 'agentSession.options') {
return new Promise(() => {})
}
@@ -150,25 +173,34 @@ describe('a permission pick on this device', () => {
AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY,
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY
])
mocks.call.mockResolvedValue({
models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }],
current: { model: 'model-a', permissionMode: 'own', reportedPermissionMode: 'full-access' }
})
vi.mocked(mutate).mockResolvedValue({ options: { permissionMode: 'full-access' } })
optionsAnswering('own')
const read = mocks.call.getMockImplementation()!
mocks.call.mockImplementation((target, method, params) =>
method === 'agentSession.options'
? Promise.resolve({
models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }],
current: {
model: 'model-a',
permissionMode: 'own',
reportedPermissionMode: 'full-access'
}
})
: read(target, method, params)
)
vi.mocked(enqueueSessionOptionSettingsWrite).mockClear()
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('full-access'))
await act(async () => {
expect(await result.current.setStructuredOption('permissionMode', 'full-access')).toBe(true)
})
expect(mutate).toHaveBeenCalledExactlyOnceWith(
'agentSession.setOption',
'agentSession.setOption',
{
expect(mocks.write).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
envelope: expect.objectContaining({ sessionId: 'session-1', expectedRuntimeFence: 1 }),
key: 'permissionMode',
value: 'full-access'
}
})
)
expect(mutate).not.toHaveBeenCalled()
expect(enqueueSessionOptionSettingsWrite).not.toHaveBeenCalled()
unmount()
})
@@ -179,16 +211,49 @@ describe('a permission pick on this device', () => {
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
expect(await result.current.setStructuredOption('permissionMode', 'own')).toBe(false)
expect(mutate).not.toHaveBeenCalled()
expect(mocks.write).not.toHaveBeenCalled()
unmount()
setLocalRuntimeCapabilitiesForTests(null)
})
it('dispatches supported Own reset without locking permission or the next model pick', async () => {
setLocalRuntimeCapabilitiesForTests([
AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY,
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY
])
optionsAnswering('ask')
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
const reply = Promise.withResolvers<AgentSessionMutationResult<AgentSessionOptionResult>>()
mocks.write.mockReturnValueOnce(reply.promise)
let picking!: Promise<boolean>
act(() => {
picking = result.current.setStructuredOption('permissionMode', 'own')
})
expect(mocks.write).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
key: 'permissionMode',
value: 'own',
envelope: expect.objectContaining({ expectedRuntimeFence: 1 })
})
)
expect(result.current.sendSource.optionStateRef.current.pendingId).toBeNull()
expect(result.current.optionSnapshot.find((row) => row.id === 'model')?.settable).toBe(true)
await act(async () => {
reply.resolve(acceptedReply('permissionMode', 'own'))
await picking
})
await expect(picking).resolves.toBe(true)
expect(result.current.sendSource.optionStateRef.current.pendingId).toBeNull()
expect(result.current.optionSnapshot.find((row) => row.id === 'model')?.settable).toBe(true)
unmount()
})
it('shows at once and stays pickable while the write waits behind a start', async () => {
optionsAnswering('ask', 'auto')
const first = Promise.withResolvers<{ options: Record<string, string> }>()
const second = Promise.withResolvers<{ options: Record<string, string> }>()
vi.mocked(mutate).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const first = Promise.withResolvers<AgentSessionMutationResult<AgentSessionOptionResult>>()
const second = Promise.withResolvers<AgentSessionMutationResult<AgentSessionOptionResult>>()
mocks.write.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
@@ -200,36 +265,39 @@ describe('a permission pick on this device', () => {
void result.current.setStructuredOption('permissionMode', 'auto')
})
expect(permission(result.current.optionSnapshot)).toBe('auto')
expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2)
await act(async () => second.resolve({ options: { permissionMode: 'auto' } }))
await act(async () => first.resolve({ options: { permissionMode: 'full-access' } }))
expect(mocks.write).toHaveBeenCalledTimes(2)
await act(async () => second.resolve(acceptedReply('permissionMode', 'auto')))
await act(async () => first.resolve(acceptedReply('permissionMode', 'full-access')))
expect(permission(result.current.optionSnapshot)).toBe('auto')
unmount()
// A permission commit clears generic pendingId while the model request still runs.
let model = 'model-a'
mocks.call.mockImplementation((_target: unknown, method: string) =>
Promise.resolve(
method === 'agentSession.options'
? {
models: ['model-a', 'model-b'].map((id) => ({
id,
label: id,
isDefault: false,
efforts: [],
supportsAutoMode: true
})),
current: { model, permissionMode: model === 'model-a' ? 'ask' : 'full-access' }
}
: { origin: 'unknown' }
)
forgetStructuredSessionSelection('session-1', LOCAL_TARGET)
mocks.call.mockImplementation((_target: unknown, method: string, params: unknown) =>
method === 'agentSession.setOption'
? mocks.write(params)
: Promise.resolve(
method === 'agentSession.options'
? {
models: ['model-a', 'model-b'].map((id) => ({
id,
label: id,
isDefault: false,
efforts: [],
supportsAutoMode: true
})),
current: { model, permissionMode: model === 'model-a' ? 'ask' : 'full-access' }
}
: { origin: 'unknown' }
)
)
const modelWrite = Promise.withResolvers<{ options: Record<string, string> }>()
const modeWrite = Promise.withResolvers<{ options: Record<string, string> }>()
vi.mocked(mutate)
const modelWrite = Promise.withResolvers<AgentSessionMutationResult<AgentSessionOptionResult>>()
const modeWrite = Promise.withResolvers<AgentSessionMutationResult<AgentSessionOptionResult>>()
mocks.write
.mockReturnValueOnce(modelWrite.promise)
.mockReturnValueOnce(modeWrite.promise)
.mockResolvedValue({ options: { permissionMode: 'auto' } })
.mockResolvedValue(acceptedReply('permissionMode', 'auto'))
const claude = renderReopened(1, 'claude')
await waitFor(() => expect(permission(claude.result.current.optionSnapshot)).toBe('ask'))
const choices = (snapshot: readonly SessionOptionDescriptor[]) => {
@@ -253,17 +321,20 @@ describe('a permission pick on this device', () => {
const returned = await claude.result.current.optionSurface.setOption('permissionMode', 'auto')
expect(choices(returned.snapshot)).toContain('auto')
})
expect(vi.mocked(mutate)).toHaveBeenCalledTimes(6)
expect(mocks.write).toHaveBeenCalledTimes(6)
model = 'model-b'
await act(async () => modeWrite.resolve({ options: { permissionMode: 'full-access' } }))
await act(async () => modelWrite.resolve({ options: { model } }))
await act(async () => modeWrite.resolve(acceptedReply('permissionMode', 'full-access')))
await act(async () => modelWrite.resolve(acceptedReply('model', model)))
expect(choices(claude.result.current.optionSnapshot)).toContain('auto')
claude.unmount()
})
it("shows the host's mode again when the write is refused", async () => {
optionsAnswering('ask')
vi.mocked(mutate).mockResolvedValue(null)
mocks.write.mockResolvedValue({
ok: false,
refusal: { code: 'agent_session_operation_capacity', message: 'Refused' }
})
const { result, unmount } = renderReopened(1)
await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask'))
@@ -111,7 +111,7 @@ function renderNewChat(agent: string) {
turnId: null,
unloadedTurnRevisions: undefined,
mutate: vi.fn(),
launch: { kind: 'new', heldOptions: {}, worktree: 'id:wt-1' }
launch: { kind: 'new', worktree: 'id:wt-1' }
})
frames.push(pill(options.optionSnapshot))
return options
@@ -0,0 +1,289 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { vi, type Mock } from 'vitest'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import type * as LaunchOptions from '@/lib/structured-agent-session-launch-options'
import type * as HostCapability from '@/runtime/structured-agent-session-host-capability'
import type { SelectionOutcome } from '../../../../shared/structured-agent-session-selection-operations'
type OptionsFixtureMocks = {
call: Mock
enqueue: Mock
pick: Mock
target: RuntimeClientTarget
agent: 'codex' | 'claude'
hold: Mock<
(
sessionId: string,
id: string,
encoded: string,
picker?: unknown
) => Promise<SelectionOutcome> | null
>
toastError: Mock<(message: string) => void>
coherentCatalog: boolean
}
const mocks: OptionsFixtureMocks = vi.hoisted(() => {
const selection: { target: RuntimeClientTarget; agent: 'codex' | 'claude' } = {
target: { kind: 'local' },
agent: 'codex'
}
return {
call: vi.fn(),
enqueue: vi.fn(),
pick: vi.fn(),
...selection,
hold: vi.fn<
(
sessionId: string,
id: string,
encoded: string,
picker?: unknown
) => Promise<SelectionOutcome> | null
>(),
toastError: vi.fn<(message: string) => void>(),
coherentCatalog: false
}
})
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/runtime/structured-agent-session-host-capability', async () => {
const actual = await vi.importActual<typeof HostCapability>(
'@/runtime/structured-agent-session-host-capability'
)
return {
...actual,
useStructuredAgentSessionHostCapability: (
target: RuntimeClientTarget,
capability: Parameters<typeof actual.useStructuredAgentSessionHostCapability>[1]
) => {
const supported = actual.useStructuredAgentSessionHostCapability(target, capability)
return (
(mocks.coherentCatalog && capability === 'agent-session.coherent-model-catalog.v1') ||
supported
)
}
}
})
vi.mock('@/runtime/structured-agent-session-client', () => ({
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
callStructuredAgentSession: mocks.call
}))
vi.mock('./native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: mocks.enqueue
}))
vi.mock('@/lib/structured-agent-session-launch-options', async () => {
const { structuredSessionSelectionController } =
await import('@/lib/structured-agent-session-selection-controller')
const actual = await vi.importActual<typeof LaunchOptions>(
'@/lib/structured-agent-session-launch-options'
)
return {
...actual,
holdStructuredAgentSessionLaunchOption: (...args: Parameters<typeof mocks.hold>) => {
const reply = mocks.hold(...args)
if (!reply) {
return null
}
const controller = structuredSessionSelectionController(mocks.target, args[0], mocks.agent)
const operation = controller.issue(
args[1],
args[2],
args[1] === 'model' ? args[2] : 'gpt-5.5'
)
void reply.then((outcome) => controller.settle(operation, outcome))
return operation.result
},
getStructuredAgentSessionLaunchSelection: () => null
}
})
import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
const LOCAL_TARGET = { kind: 'local' } as const
const PAIRED_TARGET = { kind: 'environment', environmentId: 'server-1' } as const
class FakeRpcCallError extends Error {
constructor(readonly code: string) {
super(code)
}
}
type Answers = {
options?: () => Promise<unknown>
modelCatalog?: () => Promise<unknown>
}
function answer(answers: Answers): void {
mocks.call.mockImplementation(
async (_target: unknown, method: string, params: { key: string; value: string }) => {
if (method === 'agentSession.setOption') {
const value = await mocks.pick(method, method, { key: params.key, value: params.value })
return value ? { ok: true, value } : Promise.reject(new Error('Unconfirmed option write'))
}
if (method === 'agentSession.options' && answers.options) {
return answers.options()
}
if (method === 'agentSession.modelCatalog' && answers.modelCatalog) {
return answers.modelCatalog()
}
return new Promise(() => {})
}
)
}
type MutateCall = (...args: unknown[]) => Promise<unknown>
function mutateWith(reply: MutateCall): {
mutate: StructuredAgentSessionMutate
calls: ReturnType<typeof vi.fn<MutateCall>>
} {
const calls = vi.fn<MutateCall>(reply)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each reply is null or the AgentSessionOptionResult shape the hook reads.
return { mutate: calls as unknown as StructuredAgentSessionMutate, calls }
}
type RenderProps = {
transportEnabled: boolean
fence: number | null
turnId?: string | null
launch?: 'new' | 'resume'
hidden?: boolean
launchSeedOptions?: Record<string, string>
worktree?: string
agent?: 'claude' | 'codex'
providerStarting?: boolean
paired?: boolean
}
const PROVISIONAL: RenderProps = { transportEnabled: false, fence: null, launch: 'new' }
// The receipt lands first; attach delivers the fence on a later render.
const PUBLISHED_UNATTACHED: RenderProps = { transportEnabled: true, fence: null, launch: 'new' }
const ATTACHED: RenderProps = { transportEnabled: true, fence: 1, launch: 'new' }
const REOPENED: RenderProps = { transportEnabled: true, fence: 1 }
function renderOptions(initial: RenderProps, mutate: StructuredAgentSessionMutate) {
mocks.pick.mockImplementation(mutate)
mocks.target = initial.paired ? PAIRED_TARGET : LOCAL_TARGET
mocks.agent = initial.agent ?? 'codex'
return renderHook(
(props: RenderProps) =>
useStructuredAgentSessionOptions({
agent: props.agent ?? 'codex',
sessionId: 'session-1',
target: props.paired ? PAIRED_TARGET : LOCAL_TARGET,
transportEnabled: props.transportEnabled,
isVisible: !props.hidden,
providerVisible: props.transportEnabled && !props.hidden,
...(props.providerStarting ? { providerStarting: true } : {}),
fence: props.fence,
turnId: props.turnId ?? null,
unloadedTurnRevisions: undefined,
mutate,
...(props.launch
? {
launch: {
kind: props.launch,
...(props.launchSeedOptions ? { seedOptions: props.launchSeedOptions } : {}),
...(props.worktree ? { worktree: props.worktree } : {})
}
}
: {})
}),
{ initialProps: initial }
)
}
function descriptor(snapshot: readonly SessionOptionDescriptor[], id: string) {
return snapshot.find((entry) => entry.id === id)
}
function currentValue(snapshot: readonly SessionOptionDescriptor[], id: string) {
const entry = descriptor(snapshot, id)
return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null
}
function modelChoiceCount(snapshot: readonly SessionOptionDescriptor[]): number {
const model = descriptor(snapshot, 'model')
return model?.kind.type === 'select' ? model.kind.choices.length : 0
}
function setOptionCalls(calls: ReturnType<typeof vi.fn<MutateCall>>): unknown[] {
return calls.mock.calls
.filter(([method]) => method === 'agentSession.setOption')
.map(([, , fields]) => fields)
}
const HOST_CATALOG = {
origin: 'live-session',
models: [
{
id: 'gpt-hosted',
label: 'GPT Hosted',
isDefault: true,
efforts: [{ value: 'high', label: 'High' }]
}
],
fetchedAt: 1_000
}
const LIVE_OPTIONS = {
models: [
{ id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] },
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] }
],
current: { model: 'gpt-5.5', confirmed: ['model'] }
}
const SAVED_LIST = {
origin: 'probe',
models: [
{
id: 'gpt-5.5',
label: 'GPT-5.5',
isDefault: true,
efforts: [
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
]
},
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] }
],
fetchedAt: 1_000
}
const SEED = { model: 'gpt-5.5' }
const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
export {
mocks,
LOCAL_TARGET,
PAIRED_TARGET,
FakeRpcCallError,
answer,
mutateWith,
PROVISIONAL,
PUBLISHED_UNATTACHED,
ATTACHED,
REOPENED,
renderOptions,
descriptor,
currentValue,
modelChoiceCount,
setOptionCalls,
HOST_CATALOG,
LIVE_OPTIONS,
SAVED_LIST,
SEED,
tick
}
export type { Answers, MutateCall, RenderProps }
@@ -1,196 +1,101 @@
// @vitest-environment happy-dom
import {
mocks,
LOCAL_TARGET,
PAIRED_TARGET,
FakeRpcCallError,
answer,
mutateWith,
PROVISIONAL,
PUBLISHED_UNATTACHED,
ATTACHED,
REOPENED,
renderOptions,
descriptor,
currentValue,
modelChoiceCount,
setOptionCalls,
HOST_CATALOG,
LIVE_OPTIONS,
SAVED_LIST,
SEED,
tick
} from './use-structured-agent-session-options.test-fixture'
import { act, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
enqueue: vi.fn(),
hold: vi.fn<
(sessionId: string, id: string, encoded: string, picker?: unknown) => Promise<unknown> | null
>(),
toastError: vi.fn<(message: string) => void>()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })),
callStructuredAgentSession: mocks.call
}))
vi.mock('./native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: mocks.enqueue
}))
vi.mock('@/lib/structured-agent-session-launch-options', () => ({
holdStructuredAgentSessionLaunchOption: mocks.hold,
getStructuredAgentSessionLaunchSelection: () => null
}))
import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view'
import {
structuredAgentSessionSendSelection,
structuredAgentSessionMessageSelection
} from '../../../../shared/structured-agent-session-option-view'
import {
forgetStructuredSessionSelection,
structuredSessionSelectionController
} from '@/lib/structured-agent-session-selection-controller'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots'
const LOCAL_TARGET = { kind: 'local' } as const
const PAIRED_TARGET = { kind: 'environment', environmentId: 'server-1' } as const
class FakeRpcCallError extends Error {
constructor(readonly code: string) {
super(code)
}
}
type Answers = {
options?: () => Promise<unknown>
modelCatalog?: () => Promise<unknown>
}
function answer(answers: Answers): void {
mocks.call.mockImplementation((_target: unknown, method: string) => {
if (method === 'agentSession.options' && answers.options) {
return answers.options()
}
if (method === 'agentSession.modelCatalog' && answers.modelCatalog) {
return answers.modelCatalog()
}
return new Promise(() => {})
})
}
type MutateCall = (...args: unknown[]) => Promise<unknown>
function mutateWith(reply: MutateCall): {
mutate: StructuredAgentSessionMutate
calls: ReturnType<typeof vi.fn<MutateCall>>
} {
const calls = vi.fn<MutateCall>(reply)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each reply is null or the AgentSessionOptionResult shape the hook reads.
return { mutate: calls as unknown as StructuredAgentSessionMutate, calls }
}
type RenderProps = {
transportEnabled: boolean
fence: number | null
turnId?: string | null
launch?: 'new' | 'resume'
hidden?: boolean
launchSeedOptions?: Record<string, string>
heldOptions?: Record<string, string>
worktree?: string
agent?: 'claude' | 'codex'
providerStarting?: boolean
paired?: boolean
}
const PROVISIONAL: RenderProps = { transportEnabled: false, fence: null, launch: 'new' }
// The receipt lands first; attach delivers the fence on a later render.
const PUBLISHED_UNATTACHED: RenderProps = { transportEnabled: true, fence: null, launch: 'new' }
const ATTACHED: RenderProps = { transportEnabled: true, fence: 1, launch: 'new' }
const REOPENED: RenderProps = { transportEnabled: true, fence: 1 }
function renderOptions(initial: RenderProps, mutate: StructuredAgentSessionMutate) {
return renderHook(
(props: RenderProps) =>
useStructuredAgentSessionOptions({
agent: props.agent ?? 'codex',
sessionId: 'session-1',
target: props.paired ? PAIRED_TARGET : LOCAL_TARGET,
transportEnabled: props.transportEnabled,
isVisible: !props.hidden,
providerVisible: props.transportEnabled && !props.hidden,
...(props.providerStarting ? { providerStarting: true } : {}),
fence: props.fence,
turnId: props.turnId ?? null,
unloadedTurnRevisions: undefined,
mutate,
...(props.launch
? {
launch: {
kind: props.launch,
...(props.launchSeedOptions ? { seedOptions: props.launchSeedOptions } : {}),
heldOptions: props.heldOptions ?? {},
...(props.worktree ? { worktree: props.worktree } : {})
}
}
: {})
}),
{ initialProps: initial }
)
}
function descriptor(snapshot: readonly SessionOptionDescriptor[], id: string) {
return snapshot.find((entry) => entry.id === id)
}
function currentValue(snapshot: readonly SessionOptionDescriptor[], id: string) {
const entry = descriptor(snapshot, id)
return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null
}
function modelChoiceCount(snapshot: readonly SessionOptionDescriptor[]): number {
const model = descriptor(snapshot, 'model')
return model?.kind.type === 'select' ? model.kind.choices.length : 0
}
function setOptionCalls(calls: ReturnType<typeof vi.fn<MutateCall>>): unknown[] {
return calls.mock.calls
.filter(([method]) => method === 'agentSession.setOption')
.map(([, , fields]) => fields)
}
const HOST_CATALOG = {
origin: 'live-session',
models: [
{
id: 'gpt-hosted',
label: 'GPT Hosted',
isDefault: true,
efforts: [{ value: 'high', label: 'High' }]
}
],
fetchedAt: 1_000
}
const LIVE_OPTIONS = {
models: [
{ id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] },
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] }
],
current: { model: 'gpt-5.5', confirmed: ['model'] }
}
const SAVED_LIST = {
origin: 'probe',
models: [
{
id: 'gpt-5.5',
label: 'GPT-5.5',
isDefault: true,
efforts: [
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
]
},
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] }
],
fetchedAt: 1_000
}
const SEED = { model: 'gpt-5.5' }
const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
describe('useStructuredAgentSessionOptions', () => {
beforeEach(() => {
forgetStructuredSessionSelection('session-1')
mocks.call.mockReset()
mocks.enqueue.mockReset()
mocks.hold.mockReset()
mocks.toastError.mockReset()
mocks.coherentCatalog = false
resetHostModelCatalogSnapshotsForTests()
})
it('retains a queried seed-catalog base through same-session attach and later metadata', async () => {
mocks.coherentCatalog = true
let landCatalog = (_value: unknown): void => {}
const catalog = new Promise((resolve) => (landCatalog = resolve))
let reads = 0
answer({
modelCatalog: () => catalog,
options: () =>
++reads === 1
? Promise.resolve({
...LIVE_OPTIONS,
current: { model: 'gpt-5.5', defaulted: ['model'], acceptedInputSources: {} }
})
: new Promise(() => {})
})
const stale = { model: 'old-unlisted', effort: 'high' }
const props = { ...ATTACHED, launchSeedOptions: stale }
const { result, rerender, unmount } = renderOptions(props, mutateWith(async () => null).mutate)
const capture = () => {
const { optionStateRef, optionView } = result.current.sendSource
return structuredAgentSessionMessageSelection(
optionView(optionStateRef.current, stale, stale)
)
}
await waitFor(() =>
expect(result.current.sendSource.optionStateRef.current.optionsAnswerPresent).toBe(true)
)
expect(result.current.sendSource.optionStateRef.current.catalogSource).toBe('seed')
expect(capture()).toEqual({})
rerender({ ...props, fence: 2 })
expect(capture()).toEqual({})
expect(result.current.sendSource.optionStateRef.current.optionsAnswerPresent).toBe(true)
await act(async () => {
landCatalog({ ...SAVED_LIST, listingNamesConfiguredModel: true })
await tick()
})
expect(capture()).toEqual({})
await act(async () => {
structuredSessionSelectionController(LOCAL_TARGET, 'session-1', 'codex').issue(
'effort',
'low',
'gpt-5.5'
)
})
expect(capture()).toEqual({ effort: 'low' })
rerender({ ...props, fence: 3 })
expect(capture()).toEqual({ effort: 'low' })
unmount()
forgetStructuredSessionSelection('session-1')
})
it('upgrades the seed with the host catalog while the live read is still pending', async () => {
answer({ modelCatalog: () => Promise.resolve(HOST_CATALOG) })
const { result, unmount } = renderOptions(ATTACHED, mutateWith(async () => null).mutate)
@@ -318,8 +223,14 @@ describe('useStructuredAgentSessionOptions', () => {
accepted = await result.current.setStructuredOption('model', 'gpt-5.6-luna')
})
expect(accepted).toBe(true)
expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-5.6-luna')
rerender({ ...PROVISIONAL, launchSeedOptions: SEED, heldOptions: { model: 'gpt-5.6-luna' } })
expect(mocks.hold).toHaveBeenCalledWith(
'session-1',
'model',
'gpt-5.6-luna',
expect.anything()
)
// The launch owns the pick; the view renders what it holds.
rerender({ ...PROVISIONAL, launchSeedOptions: SEED })
expect(currentValue(result.current.optionSnapshot, 'model')).toBe('gpt-5.6-luna')
expect(descriptor(result.current.optionSnapshot, 'model')?.valueSource).toBe('dispatched')
expect(calls).not.toHaveBeenCalled()
@@ -584,6 +495,7 @@ describe('useStructuredAgentSessionOptions', () => {
})
describe("a paired server's new chat", () => {
beforeEach(() => replaceRuntimeEnvironmentRevisions([{ id: 'server-1', createdAt: 1 }]))
it("shows the server's seed once its list answers and remembers a pick on the server under its model", async () => {
answer({ modelCatalog: () => Promise.resolve(SAVED_LIST) })
mocks.hold.mockResolvedValue({ kind: 'accepted', options: { effort: 'high' } })
@@ -600,11 +512,15 @@ describe('useStructuredAgentSessionOptions', () => {
await result.current.setStructuredOption('effort', 'high')
})
await waitFor(() =>
expect(mocks.enqueue).toHaveBeenCalledWith(PAIRED_TARGET, {
type: 'apply-picks',
agent: 'codex',
picks: [{ modelId: 'gpt-5.5', optionId: 'effort', value: 'high' }]
})
expect(mocks.enqueue).toHaveBeenCalledWith(
PAIRED_TARGET,
{
type: 'apply-picks',
agent: 'codex',
picks: [{ modelId: 'gpt-5.5', optionId: 'effort', value: 'high' }]
},
{ expectedEnvironmentPairingRevision: 1 }
)
)
unmount()
})
@@ -624,7 +540,7 @@ describe('useStructuredAgentSessionOptions', () => {
await act(async () => {
await result.current.setStructuredOption('model', 'gpt-hosted')
})
expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted')
expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted', expect.anything())
unmount()
})
@@ -686,7 +602,7 @@ describe('useStructuredAgentSessionOptions', () => {
)
const { optionStateRef, optionView } = result.current.sendSource
expect(
structuredAgentSessionSendSelection(optionView(optionStateRef.current, undefined, {}))
structuredAgentSessionSendSelection(optionView(optionStateRef.current, undefined))
).toEqual({ model: 'opus', effort: 'high' })
unmount()
}
@@ -732,8 +648,13 @@ describe('useStructuredAgentSessionOptions', () => {
current: { ...pick.current, confirmed: ['model'] }
}
let reads = 0
// The read after the pick never lands, so only the reply speaks.
answer({ options: () => (++reads === 1 ? Promise.resolve(live) : new Promise(() => {})) })
// The authoritative read supplies model-dependent normalization; replies settle operations.
answer({
options: async () =>
++reads === 1
? live
: { ...live, current: { ...live.current, model: 'second', ...pick.reply } }
})
const { mutate } = mutateWith(async () => ({
key: 'model',
value: 'second',
@@ -1,55 +1,33 @@
import type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract'
import { toast } from 'sonner'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire'
import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog'
import type { SessionOptionsSurface } from '../../../../shared/native-chat/input/native-chat-session-options'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
import {
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
lockedStructuredAgentSessionOptionSnapshot,
structuredAgentSessionOptionSnapshot,
type StructuredAgentSessionOptionState
structuredAgentSessionOptionPicks
} from '../../../../shared/structured-agent-session-options'
import { useRememberSessionOptionPicks } from './use-structured-agent-session-option-picks'
import {
beginStructuredOptionPick,
isLatestStructuredOptionPick,
settleStructuredOptionPick,
structuredOptionPickCommit,
structuredOptionPickBlockingId
} from '../../../../shared/structured-agent-session-option-pick-book'
import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec'
import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade'
import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state'
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
import {
getStructuredAgentSessionLaunchSelection,
holdStructuredAgentSessionLaunchOption,
type StructuredLaunchOptionOutcome
flushStructuredSessionSelectionOptions,
holdStructuredAgentSessionLaunchOption
} from '@/lib/structured-agent-session-launch-options'
export type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract'
const NO_HELD_OPTIONS: Readonly<Record<string, string>> = {}
export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOptionsArgs) {
const {
agent,
fence,
launch,
mutate,
providerVisible,
sessionId,
target,
transportEnabled,
turnId
} = args
const { agent, fence, launch, providerVisible, sessionId, target, transportEnabled, turnId } =
args
const capturePresentation = args.capturePresentation
const providerStarting = args.providerStarting ?? false
const newLaunch = launch?.kind === 'new'
const launchSeedOptions = launch?.seedOptions
const held = launch?.heldOptions ?? NO_HELD_OPTIONS
const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent])
const {
optionView,
@@ -57,10 +35,11 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt
optionState,
optionStateRef,
activeOptionRecordRef,
optionPicks,
controller,
takesSelection,
updateOptionState,
rereadOptions,
receiveCatalog,
beginOptionWrite,
isCurrentContext,
conversationSupport
} = useStructuredAgentSessionOptionState({
@@ -106,95 +85,6 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt
receiveCatalog
})
const pendingView = useCallback(
(state: StructuredAgentSessionOptionState) =>
optionView(state, launchSeedOptions, held, launch?.explicitOptions),
[held, launch?.explicitOptions, launchSeedOptions, optionView]
)
const rememberOptionPicks = useRememberSessionOptionPicks(target, agent)
const sendStructuredOption = useCallback(
async (id: string, encoded: string): Promise<boolean> => {
const currentState = optionStateRef.current
const targetRecord = currentState.record
const finishWrite = beginOptionWrite(id)
// Show record-only picks immediately while their writes wait behind provider startup.
const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
const pick = beginStructuredOptionPick(optionPicks.current, id, encoded, shownAtOnce)
const sameRecord = (): boolean =>
isCurrentContext() && activeOptionRecordRef.current === targetRecord
const isCurrent = (): boolean =>
sameRecord() && isLatestStructuredOptionPick(optionPicks.current, pick)
updateOptionState((current) =>
shownAtOnce
? commitStructuredAgentSessionOptionValues(current, { [id]: encoded })
: { ...current, pendingId: id }
)
let result: AgentSessionOptionResult | null = null
try {
result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value: encoded }
)
const committed =
result && sameRecord()
? structuredOptionPickCommit(optionPicks.current, pick, result.options)
: null
if (committed) {
updateOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOptionValues(current, committed)
: current
)
// The launch seed names the model an effort-only pick was made under.
rememberOptionPicks(
optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS),
committed
)
}
return Boolean(result)
} finally {
if (sameRecord()) {
settleStructuredOptionPick(optionPicks.current, pick)
}
if (isCurrent()) {
updateOptionState((current) =>
current.record === targetRecord && current.pendingId === id
? { ...current, pendingId: null }
: current
)
}
finishWrite(isCurrent() && Boolean(result || shownAtOnce))
}
},
[
activeOptionRecordRef,
beginOptionWrite,
isCurrentContext,
launchSeedOptions,
mutate,
optionPicks,
optionStateRef,
optionView,
providerStarting,
rememberOptionPicks,
updateOptionState
]
)
const settleLaunchOptionPick = useCallback(
(outcome: StructuredLaunchOptionOutcome) => {
if (outcome.kind === 'refused') {
toast.error(agentSessionWriteFailureText(outcome.failure, 'option'))
} else if (outcome.kind === 'accepted') {
rememberOptionPicks(
optionView(optionStateRef.current, launchSeedOptions, NO_HELD_OPTIONS),
outcome.options
)
}
},
[launchSeedOptions, optionStateRef, optionView, rememberOptionPicks]
)
const interactionReadyRef = useRef(acceptsPicks)
const lockSnapshotRef = useRef(
(snapshot: ReturnType<typeof structuredAgentSessionOptionSnapshot>) => snapshot
@@ -206,24 +96,33 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt
lockSnapshotRef.current = lockSnapshot
})
const optionSnapshot = lockSnapshot(
structuredAgentSessionOptionSnapshot(pendingView(optionState))
structuredAgentSessionOptionSnapshot(
optionView(optionState, launchSeedOptions, launch?.explicitOptions)
)
)
const setStructuredOption = useCallback(
async (id: string, value: string | boolean): Promise<boolean> => {
const view = pendingView(optionStateRef.current)
const view = optionView(optionStateRef.current, launchSeedOptions, launch?.explicitOptions)
const pickState = {
...view,
pendingId:
providerStarting ||
id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ||
!controller.isPending(id)
? null
: id
}
const encoded = encodeStructuredAgentSessionOptionValue(id, value)
if (
(id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID &&
value === 'own' &&
!view.permissionOwnSupported) ||
!isCurrentContext() ||
!acceptsPicks ||
!optionCatalog ||
encoded === null ||
!canSetStructuredAgentSessionOption(
{
...view,
pendingId: structuredOptionPickBlockingId(optionPicks.current, id, providerStarting)
},
pickState,
id,
value,
lockSnapshotRef.current(structuredAgentSessionOptionSnapshot(view)),
@@ -233,41 +132,69 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt
return false
}
if (!transportEnabled || fence === null) {
// No fence yet: the launch holds it and applies it before anything else is sent.
const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded)
void applied?.then(settleLaunchOptionPick)
const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded, view)
void applied?.then((outcome) => {
if (outcome.kind === 'refused' && outcome.failure) {
toast.error(agentSessionWriteFailureText(outcome.failure, 'option'))
}
rereadOptions()
})
return applied !== null
}
return sendStructuredOption(id, encoded)
const context = structuredAgentSessionOptionPicks(view, { [id]: encoded })[0]?.modelId ?? null
const presents = capturePresentation?.()
const operation = controller.issue(id, encoded, context)
updateOptionState((current) => ({
...current,
pendingId: providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ? null : id
}))
void flushStructuredSessionSelectionOptions(target, sessionId, fence, controller)
const outcome = await operation.result
updateOptionState((current) => ({
...current,
pendingId: controller.pendingId()
}))
if (
(outcome.kind === 'refused' || outcome.kind === 'unknown') &&
outcome.failure &&
(presents?.() ?? true)
) {
toast.error(agentSessionWriteFailureText(outcome.failure, 'option'))
}
rereadOptions()
return outcome.kind !== 'refused'
},
[
controller,
capturePresentation,
acceptsPicks,
fence,
launch?.explicitOptions,
launchSeedOptions,
target,
optionCatalog,
isCurrentContext,
optionStateRef,
optionPicks,
pendingView,
optionView,
providerStarting,
sendStructuredOption,
rereadOptions,
sessionId,
settleLaunchOptionPick,
transportEnabled
transportEnabled,
updateOptionState
]
)
const setOption = useCallback(
async (id: string, value: string | boolean) => {
await setStructuredOption(id, value)
// Read held picks directly; an accepted launch pick may precede this render’s props.
const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held
return {
snapshot: lockSnapshotRef.current(
structuredAgentSessionOptionSnapshot(
optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS)
optionView(optionStateRef.current, launchSeedOptions, launch?.explicitOptions)
)
)
}
},
[launchSeedOptions, optionStateRef, optionView, sessionId, setStructuredOption]
[launch?.explicitOptions, launchSeedOptions, optionStateRef, optionView, setStructuredOption]
)
const optionSurface = useMemo<SessionOptionsSurface>(
() => ({
@@ -297,7 +224,7 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt
optionSnapshot,
optionSurface,
setStructuredOption,
sendSource: { optionStateRef, optionPicks, optionView, takesSendOptions },
sendSource: { optionStateRef, optionView, takesSendOptions, takesSelection },
unavailable: hostCatalog.unavailable
}
}
@@ -139,7 +139,7 @@ describe('useStructuredAgentSession provisional launch gate', () => {
agent: 'codex',
isVisible: true,
transportEnabled: false,
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' } }
})
)
@@ -180,7 +180,7 @@ describe('useStructuredAgentSession provisional launch gate', () => {
agent: 'grok',
isVisible: true,
transportEnabled: false,
launch: { kind: 'new', heldOptions: {} }
launch: { kind: 'new' }
})
)
expect(render().result.current.canStop).toBe(false)
@@ -236,9 +236,14 @@ describe('useStructuredAgentSession provisional launch gate', () => {
rerender({ transportEnabled: true })
await waitFor(() =>
expect(mocks.call).toHaveBeenCalledWith(LOCAL_TARGET, 'agentSession.options', {
sessionId: 'session-1'
})
expect(mocks.call).toHaveBeenCalledWith(
LOCAL_TARGET,
'agentSession.options',
{
sessionId: 'session-1'
},
{}
)
)
expect(methodsCalled().filter((method) => method === 'agentSession.options')).toHaveLength(1)
expect(mocks.hold).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: true }))
@@ -10,8 +10,10 @@ vi.mock('@/lib/structured-agent-session-launch-options', () => ({
import { CLAUDE_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-claude-codex'
import {
applyStructuredAgentSessionModelCatalog,
applyStructuredAgentSessionOptions,
createStructuredAgentSessionOptionState
} from '../../../../shared/structured-agent-session-options'
import { projectRuntimePermission } from '../../../../shared/structured-agent-session-permission-option'
import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view'
import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options'
@@ -20,7 +22,8 @@ const levels = (...values: string[]) => values.map((value) => ({ value, label: v
function sendOptionsFor(
takesSendOptions: boolean,
pending: Record<string, string>,
seedOptions: Record<string, string> = { model: 'sonnet', effort: 'high' }
seedOptions: Record<string, string> = { model: 'sonnet', effort: 'high' },
takesSelection = false
) {
const optionStateRef = {
current: applyStructuredAgentSessionModelCatalog(
@@ -29,9 +32,16 @@ function sendOptionsFor(
{
origin: 'probe',
fetchedAt: 1,
fastModeSupport: { supported: true },
models: [
{ id: 'opus[1m]', label: 'Opus', isDefault: false, efforts: levels('low', 'xhigh') },
{ id: 'sonnet', label: 'Sonnet', isDefault: true, efforts: levels('low', 'high') }
{
id: 'sonnet',
label: 'Sonnet',
isDefault: true,
efforts: levels('low', 'high'),
supportsFastMode: true
}
]
},
{ newLaunch: true }
@@ -40,11 +50,18 @@ function sendOptionsFor(
const { result } = renderHook(() =>
useStructuredAgentSessionSendOptions(
'session-1',
{ kind: 'new', seedOptions, heldOptions: {} },
{ kind: 'new', seedOptions },
{
optionStateRef,
optionPicks: { current: { pending, generation: 0 } },
optionView: structuredAgentSessionOptionView,
optionView: (state, seed) => ({
...projectRuntimePermission(
structuredAgentSessionOptionView(state, seed, pending),
undefined,
null
),
permissionOwnSupported: null
}),
takesSelection,
takesSendOptions
}
)
@@ -55,21 +72,84 @@ function sendOptionsFor(
describe('what each composer send carries', () => {
it('carries the shown model and effort to a host that takes them, on every send', () => {
const sendOptions = sendOptionsFor(true, {})
expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' })
expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' })
expect(sendOptions()).toEqual({ options: { model: 'sonnet', effort: 'high' } })
expect(sendOptions()).toEqual({ options: { model: 'sonnet', effort: 'high' } })
})
it('carries a pick still on its way, resolved as the picker shows it', () => {
expect(sendOptionsFor(true, { model: 'opus[1m]', effort: 'low' })()).toMatchObject({
model: 'opus[1m]',
effort: 'low'
options: { model: 'opus[1m]', effort: 'low' }
})
expect(sendOptionsFor(true, { model: 'claude-retired' })()).toMatchObject({
model: 'claude-retired'
options: { model: 'claude-retired' }
})
})
it('captures the same full shown tuple on a capable host, including explicit Fast Off', () => {
expect(sendOptionsFor(false, { fastMode: 'false' }, undefined, true)()).toEqual({
selection: { model: 'sonnet', effort: 'high', fastMode: 'false' }
})
})
it('carries nothing to an older host', () => {
expect(sendOptionsFor(false, {})()).toBeUndefined()
expect(sendOptionsFor(false, {})()).toEqual({})
})
})
it('desktop capture freezes only accepted echoed inputs; empty origin differs from legacy absence', () => {
const state = createStructuredAgentSessionOptionState('claude', CLAUDE_SESSION_OPTION_CATALOG)
const answer = {
models: [
{
id: 'opus[1m]',
label: 'Opus',
isDefault: false,
efforts: levels('high'),
supportsFastMode: true
}
],
fastModeSupport: { supported: true },
current: {
model: 'opus[1m]',
effort: 'high',
fastMode: false,
confirmed: ['model', 'effort'],
acceptedInputSources: { model: 'saved' as const, fastMode: 'policy' as const }
}
}
const source = {
optionStateRef: {
current: applyStructuredAgentSessionOptions(state, CLAUDE_SESSION_OPTION_CATALOG, answer)
},
optionView: (current: typeof state) => ({
...projectRuntimePermission(
structuredAgentSessionOptionView(current, undefined, {}),
undefined,
null
),
permissionOwnSupported: null
}),
takesSelection: true,
takesSendOptions: false
}
const { result } = renderHook(() =>
useStructuredAgentSessionSendOptions('session-2', undefined, source)
)
const captured = result.current()
expect(captured).toEqual({ selection: { model: 'opus[1m]', fastMode: 'false' } })
source.optionStateRef.current = applyStructuredAgentSessionOptions(
state,
CLAUDE_SESSION_OPTION_CATALOG,
{ ...answer, current: { ...answer.current, acceptedInputSources: {} } }
)
expect(result.current()).toEqual({ selection: {} })
source.optionStateRef.current = applyStructuredAgentSessionOptions(
state,
CLAUDE_SESSION_OPTION_CATALOG,
{ ...answer, current: { ...answer.current, acceptedInputSources: undefined } }
)
expect(result.current()).toEqual({
selection: { model: 'opus[1m]', effort: 'high', fastMode: 'false' }
})
expect(captured).toEqual({ selection: { model: 'opus[1m]', fastMode: 'false' } })
})
@@ -1,6 +1,9 @@
import { structuredLaunchRequestedOptions } from '../../../../shared/native-chat/input/native-chat-session-option-defaults'
import { useCallback } from 'react'
import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view'
import type { StructuredAgentSessionSendControl } from '../../../../shared/structured-agent-session-send-mutation'
import {
structuredAgentSessionMessageSelection,
structuredAgentSessionSendSelection
} from '../../../../shared/structured-agent-session-option-view'
import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options'
import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch'
import type { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state'
@@ -11,29 +14,24 @@ export function useStructuredAgentSessionSendOptions(
launch: StructuredAgentSessionLaunchView | undefined,
source: Pick<
ReturnType<typeof useStructuredAgentSessionOptionState>,
'optionStateRef' | 'optionPicks' | 'optionView' | 'takesSendOptions'
'optionStateRef' | 'optionView' | 'takesSendOptions' | 'takesSelection'
>
): () => Readonly<Record<string, string>> | undefined {
const { optionStateRef, optionView, optionPicks, takesSendOptions } = source
): () => Pick<StructuredAgentSessionSendControl, 'options' | 'selection'> {
const { optionStateRef, optionView, takesSendOptions, takesSelection } = source
return useCallback(() => {
if (!takesSendOptions) {
return undefined
if (!takesSendOptions && !takesSelection) {
return {}
}
// Read held picks directly because launch acceptance can precede this render’s props.
const selection = getStructuredAgentSessionLaunchSelection(sessionId)
const requested = structuredLaunchRequestedOptions({
seed: selection?.seed ?? launch?.seedOptions,
explicit: selection?.explicit ?? launch?.explicitOptions,
held: { ...(selection?.held ?? launch?.heldOptions), ...optionPicks.current.pending }
})
if (requested?.model !== undefined) {
return requested
}
// A pick on its way resolves as the picker shows it, keeping the model it names.
const view = optionView(optionStateRef.current, selection?.seed ?? launch?.seedOptions, {
...(selection?.held ?? launch?.heldOptions),
...optionPicks.current.pending
})
return structuredAgentSessionSendSelection(view)
}, [launch, optionStateRef, optionView, optionPicks, sessionId, takesSendOptions])
// A pick on its way resolves as the picker shows it.
const view = optionView(
optionStateRef.current,
selection?.seed ?? launch?.seedOptions,
selection?.explicit ?? launch?.explicitOptions
)
return takesSelection
? { selection: structuredAgentSessionMessageSelection(view) }
: { options: structuredAgentSessionSendSelection(view) }
}, [launch, optionStateRef, optionView, sessionId, takesSendOptions, takesSelection])
}
@@ -1,3 +1,7 @@
import type {
StructuredAgentSessionSendControl,
StructuredAgentSessionAttachment
} from '../../../../shared/structured-agent-session-send-mutation'
import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability'
import { AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities'
import type { StructuredAgentSessionSendRequest } from './structured-agent-session-send-slot'
@@ -6,7 +10,6 @@ import type {
AgentJournalCursor,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import {
structuredAgentSessionQueueRequest,
@@ -43,6 +46,7 @@ export function useStructuredAgentSessionSends(args: {
/** The chat reads Stopping: a send made now is drawn after the turn being stopped. */
stopping?: boolean
/** The composer's model and effort each send carries; undefined to a host that takes none. */
sendSelection?: () => Pick<StructuredAgentSessionSendControl, 'options' | 'selection'>
sendOptions?: () => Readonly<Record<string, string>> | undefined
}) {
const {
@@ -53,6 +57,7 @@ export function useStructuredAgentSessionSends(args: {
queue,
queuedMessageIds,
sendOptions,
sendSelection,
sessionId,
stopping,
submissions,
@@ -111,7 +116,7 @@ export function useStructuredAgentSessionSends(args: {
return false
}
const delivery = structuredAgentSessionQueueRequest(queue)
const options = sendOptions?.()
const captured = sendSelection?.() ?? { options: sendOptions?.() }
const sent = sendStructuredAgentSessionMessage({
sessionId,
target,
@@ -122,7 +127,7 @@ export function useStructuredAgentSessionSends(args: {
? { usageLimitContinuationOfTurnItemId: request.usageLimitContinuationOfTurnItemId }
: {}),
...(delivery ? { delivery } : {}),
...(options ? { options } : {}),
...captured,
...(takesSkills && request?.skills?.length ? { skills: request.skills } : {}),
...(stopping ? { sentWhileStopping: true as const } : {})
})
@@ -141,7 +146,17 @@ export function useStructuredAgentSessionSends(args: {
? 'queued'
: true
},
[isWorking, queue, queuedMessageIds, sendOptions, sessionId, stopping, takesSkills, target]
[
isWorking,
queue,
queuedMessageIds,
sendSelection,
sendOptions,
sessionId,
stopping,
takesSkills,
target
]
)
return {
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client'
import { AGENT_SESSION_REWIND_RECOVERY_CAPABILITY as RECOVERY } from '../../../../shared/protocol-version'
@@ -81,6 +81,30 @@ import {
nativeChatRewindReturnedUnknownCopy
} from './native-chat-rewind-copy'
import { useAppStore } from '@/store'
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
beforeEach(() => {
useAppStore.getState().setRuntimeEnvironments([
{
id: 'ssh-host',
name: 'Original host',
createdAt: 1,
updatedAt: 1,
pairingRevision: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'original'
}
])
})
afterEach(() => {
cleanup()
forgetStructuredSessionSelection('session-1')
useAppStore.getState().setRuntimeEnvironments([])
})
const LOCAL_TARGET = { kind: 'local' } as const
beforeEach(() => {
@@ -390,7 +414,12 @@ describe('offering rewind only where the next send settles an in-doubt one', ()
mocks.remoteCapabilities = new Set()
const view = render(remote)
await waitFor(() =>
expect(mocks.call).toHaveBeenCalledWith(remote, 'agentSession.options', expect.anything())
expect(mocks.call).toHaveBeenCalledWith(
remote,
'agentSession.options',
{ sessionId: 'session-1' },
{ expectedEnvironmentPairingRevision: 1 }
)
)
await act(async () => {})
expect(view.result.current.rewind.surface).toBeUndefined()
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
@@ -79,6 +79,31 @@ function seededByNextLaunch(): Record<string, string> | undefined {
return resolveStructuredLaunchSeedOptions(persisted, 'codex')
}
import { useAppStore } from '@/store'
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
beforeEach(() => {
useAppStore.getState().setRuntimeEnvironments([
{
id: 'remote-1',
name: 'Original host',
createdAt: 1,
updatedAt: 1,
pairingRevision: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'original'
}
])
})
afterEach(() => {
cleanup()
forgetStructuredSessionSelection('session-1')
forgetStructuredSessionSelection('replacement-pane')
useAppStore.getState().setRuntimeEnvironments([])
})
const LOCAL_TARGET = { kind: 'local' } as const
const OPTIONS = {
@@ -114,6 +139,17 @@ const FAST_OPTIONS = {
current: { ...OPTIONS.current, fastMode: false, confirmed: ['fastMode'] }
}
function renderOptionsSession() {
return renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
}
describe('useStructuredAgentSession working state', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -205,14 +241,7 @@ describe('useStructuredAgentSession options', () => {
}
})
})
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
@@ -237,24 +266,18 @@ describe('useStructuredAgentSession options', () => {
? Promise.resolve(OPTIONS)
: Promise.reject(new Error('provider rejected option'))
)
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false)
expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true)
})
// Show one uncertainty notice without raw transport text or an unproved outcome.
expect(mocks.toastError).toHaveBeenCalledWith(
"Orca couldn't confirm what happened. Check the chat."
)
expect(mocks.enqueueSettingsWrite).not.toHaveBeenCalled()
expect(result.current.error).toBeNull()
expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({
settable: true
@@ -287,14 +310,7 @@ describe('useStructuredAgentSession options', () => {
}
)
})
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
@@ -305,12 +321,13 @@ describe('useStructuredAgentSession options', () => {
const mutations = mocks.call.mock.calls.filter(
([, method]) => method === 'agentSession.setOption'
)
expect(
mutations.map(
([, , params]) =>
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
)
).toEqual(['operation-1', 'operation-2'])
const ids = mutations.map(
([, , params]) =>
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
)
expect(ids).toEqual([expect.any(String), expect.any(String)])
expect(ids.every((id) => id.length > 0)).toBe(true)
expect(new Set(ids).size).toBe(2)
})
it('sends the pick again as a new operation after a pending admission refusal', async () => {
@@ -345,14 +362,7 @@ describe('useStructuredAgentSession options', () => {
}
)
})
const { result, rerender } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result, rerender } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
@@ -369,12 +379,13 @@ describe('useStructuredAgentSession options', () => {
const mutations = mocks.call.mock.calls.filter(
([, method]) => method === 'agentSession.setOption'
)
expect(
mutations.map(
([, , params]) =>
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
)
).toEqual(['operation-1', 'operation-2'])
const ids = mutations.map(
([, , params]) =>
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
)
expect(ids).toEqual([expect.any(String), expect.any(String)])
expect(ids.every((id) => id.length > 0)).toBe(true)
expect(new Set(ids).size).toBe(2)
expect(
mutations.map(
([, , params]) =>
@@ -383,40 +394,57 @@ describe('useStructuredAgentSession options', () => {
).toEqual([3, 4])
})
it('reports an option failure across startup while its pane remains current', async () => {
let reject!: (error: Error) => void
const pending = new Promise<never>((_resolve, rejectPromise) => {
reject = rejectPromise
})
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.options' || method === 'agentSession.hold'
? Promise.resolve(method === 'agentSession.options' ? OPTIONS : null)
: pending
)
const { result, rerender } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
it.each(['same-pane', 'replacement', 'unmounted'] as const)(
'keeps uncertainty presentation with its issuing pane across startup (%s)',
async (schedule) => {
let reject!: (error: Error) => void
const pending = new Promise<never>((_resolve, rejectPromise) => {
reject = rejectPromise
})
)
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
let setting!: Promise<boolean>
act(() => {
setting = result.current.setStructuredOption('model', 'gpt-fast')
})
fence = 4
rerender()
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.options' || method === 'agentSession.hold'
? Promise.resolve(method === 'agentSession.options' ? OPTIONS : null)
: pending
)
const { result, rerender, unmount } = renderHook(
({ sessionId }) =>
useStructuredAgentSession({
sessionId,
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
}),
{ initialProps: { sessionId: 'session-1' } }
)
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
let setting!: Promise<boolean>
act(() => {
setting = result.current.setStructuredOption('model', 'gpt-fast')
})
fence = 4
rerender({ sessionId: schedule === 'replacement' ? 'replacement-pane' : 'session-1' })
if (schedule === 'unmounted') {
unmount()
}
await act(async () => {
reject(new Error('stale provider failure'))
await setting
})
await act(async () => {
reject(new Error('stale provider failure'))
expect(await setting).toBe(true)
})
expect(result.current.error).toBeNull()
expect(mocks.toastError).toHaveBeenCalledOnce()
})
expect(result.current.error).toBeNull()
expect(mocks.toastError).toHaveBeenCalledTimes(schedule === 'same-pane' ? 1 : 0)
if (schedule === 'same-pane') {
expect(mocks.toastError).toHaveBeenCalledWith(
"Orca couldn't confirm what happened. Check the chat."
)
}
expect(mocks.enqueueSettingsWrite).not.toHaveBeenCalled()
expect(
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption')
).toHaveLength(1)
}
)
it('includes one background task id in the cancel fingerprint and payload', async () => {
mocks.call.mockImplementation((_target, method) =>
@@ -454,7 +482,7 @@ describe('useStructuredAgentSession options', () => {
})
})
it('remembers a model pick so the next launch seeds the pair the provider settled on', async () => {
it('remembers only the admitted model rather than reply-only companion effort', async () => {
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.options'
? Promise.resolve(OPTIONS)
@@ -467,28 +495,18 @@ describe('useStructuredAgentSession options', () => {
}
})
)
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true)
})
expect(seededByNextLaunch()).toEqual({ model: 'gpt-fast', effort: 'low' })
expect(seededByNextLaunch()).toEqual({ model: 'gpt-fast' })
expect(mocks.enqueueSettingsWrite).toHaveBeenCalledWith(LOCAL_TARGET, {
type: 'apply-picks',
agent: 'codex',
picks: [
{ modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' },
{ modelId: 'gpt-fast', optionId: 'effort', value: 'low' }
]
picks: [{ modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' }]
})
})
@@ -518,7 +536,8 @@ describe('useStructuredAgentSession options', () => {
expect(mocks.enqueueSettingsWrite).toHaveBeenCalledWith(
remoteTarget,
expect.objectContaining({ type: 'apply-picks', agent: 'codex' })
expect.objectContaining({ type: 'apply-picks', agent: 'codex' }),
{ expectedEnvironmentPairingRevision: 1 }
)
})
@@ -531,14 +550,7 @@ describe('useStructuredAgentSession options', () => {
value: { key: 'effort', value: 'high', options: { effort: 'high' } }
})
)
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
@@ -549,24 +561,17 @@ describe('useStructuredAgentSession options', () => {
expect(seededByNextLaunch()).toEqual({ model: 'gpt-live', effort: 'high' })
})
it('remembers nothing when the provider refuses the pick', async () => {
it('remembers nothing when the transport leaves the pick unconfirmed', async () => {
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.options'
? Promise.resolve(OPTIONS)
: Promise.reject(new Error('provider rejected option'))
)
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false)
expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true)
})
expect(mocks.enqueueSettingsWrite).not.toHaveBeenCalled()
@@ -627,14 +632,7 @@ describe('useStructuredAgentSession options', () => {
}
})
})
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(3))
await act(async () => {
@@ -649,15 +647,10 @@ describe('useStructuredAgentSession options', () => {
expect(mocks.enqueueSettingsWrite).toHaveBeenCalledWith(LOCAL_TARGET, {
type: 'apply-picks',
agent: 'codex',
picks: [
{ modelId: 'gpt-live', optionId: 'model', value: 'gpt-live' },
{ modelId: 'gpt-live', optionId: 'effort', value: 'medium' },
{ modelId: 'gpt-live', optionId: 'fastMode', value: true }
]
picks: [{ modelId: 'gpt-live', optionId: 'fastMode', value: true }]
})
expect(seededByNextLaunch()).toEqual({
model: 'gpt-live',
effort: 'medium',
fastMode: 'true'
})
})
@@ -691,14 +684,7 @@ describe('useStructuredAgentSession options', () => {
}
})
})
const { result, rerender } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex',
isVisible: true
})
)
const { result, rerender } = renderOptionsSession()
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(3))
items = [
{
@@ -107,6 +107,7 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs)
turnId: transportState.turnId,
unloadedTurnRevisions: state.unloadedTurnRevisions,
mutate,
capturePresentation,
...(launch ? { launch } : {})
})
const sendOptions = useStructuredAgentSessionSendOptions(sessionId, launch, sendSource)
@@ -137,7 +138,7 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs)
historyLoaded: transportEnabled && state.status === 'ready',
isWorking: transportState.isWorking,
stopping: stopControl.stopping,
sendOptions
sendSelection: sendOptions
})
const usage = useStructuredAgentSessionContextUsage(journalItems, usageSupport, state.epoch)
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained'
import type { structuredSessionSelectionAuthority } from '@/lib/structured-agent-session-selection-controller'
import type { StructuredSessionSelectionController } from '../../../../shared/structured-agent-session-selection-operations'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
/** Revalidate the queried base when local operations finish or host authority moves. */
export function useStructuredSessionSelectionReads(args: {
controller: StructuredSessionSelectionController
target: RuntimeClientTarget
authority: ReturnType<typeof structuredSessionSelectionAuthority>
hostOptionStamp: string
turnId: string | null
rereadOptions: () => void
}) {
const { controller, target, authority, hostOptionStamp, turnId, rereadOptions } = args
const [, setContact] = useState(0)
const selectionRevision = useSyncExternalStore(controller.subscribe, controller.snapshot)
const terminal = controller.terminalSnapshot()
const readStamp = JSON.stringify([hostOptionStamp, turnId])
const seenReadStamp = useRef(readStamp)
useEffect(() => {
if (seenReadStamp.current !== readStamp) {
seenReadStamp.current = readStamp
rereadOptions()
}
}, [readStamp, rereadOptions])
useEffect(() => {
if (terminal) {
rereadOptions()
}
}, [terminal, rereadOptions])
useEffect(
() =>
target.kind === 'environment'
? subscribeRuntimeHostContactRegained(target.environmentId, () => {
setContact((value) => value + 1)
rereadOptions()
})
: undefined,
[target, authority, controller, rereadOptions]
)
return selectionRevision
}
@@ -460,7 +460,7 @@ function registerLaunch(
promise: new Promise(() => undefined),
visibilityUnknown: false,
cancelled: false,
selection: { held: {} }
selection: {}
} satisfies StructuredLaunchState)
}
@@ -22,6 +22,7 @@ import {
type StructuredLaunchState
} from './structured-agent-session-launch-registry'
import { recordUnlistedCreatedChat } from '../../structured-agent-session-unlisted-created-chats'
import { forgetStructuredSessionSelection } from '../../structured-agent-session-selection-controller'
function cleanupLaunchState(state: StructuredLaunchState): void {
if (deleteStructuredLaunchStateIfCurrent(state)) {
@@ -30,11 +31,7 @@ function cleanupLaunchState(state: StructuredLaunchState): void {
}
export function maybeCleanupLaunchState(state: StructuredLaunchState): void {
if (
state.callers.outcome === 'failed' ||
structuredLaunchCallersHavePendingWork(state.callers) ||
Object.keys(state.selection.held).length > 0
) {
if (state.callers.outcome === 'failed' || structuredLaunchCallersHavePendingWork(state.callers)) {
return
}
cleanupLaunchState(state)
@@ -47,7 +44,10 @@ function cleanupUnmaterializedLaunch(state: StructuredLaunchState): boolean {
state.intent.worktreeId,
state.intent.sessionId
)
return deleteStructuredLaunchStateIfCurrent(state)
if (deleteStructuredLaunchStateIfCurrent(state)) {
forgetStructuredSessionSelection(state.intent.sessionId, state.intent.target)
return true
}
}
return false
}
@@ -1,5 +1,8 @@
import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry'
import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner'
import { launchStateLifecycle } from '../../structured-agent-session-launch-lifecycle'
export { launchStateLifecycle } from '../../structured-agent-session-launch-lifecycle'
import { forgetStructuredSessionSelection } from '../../structured-agent-session-selection-controller'
import { useSyncExternalStore } from 'react'
import type { TuiAgent } from '../../../../../shared/tui-agent'
import type { StructuredLaunchFailure } from '../../structured-agent-session-launch-failure'
@@ -60,6 +63,9 @@ const structuredLaunchesBySessionId = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
export function resetStructuredAgentLaunchRegistryForTests(): void {
for (const sessionId of structuredLaunchesBySessionId.keys()) {
forgetStructuredSessionSelection(sessionId)
}
structuredLaunchesBySessionId.clear()
structuredLaunchListeners.clear()
resetStructuredAgentLaunchCancellationForTests()
@@ -135,21 +141,6 @@ export function structuredLaunchStates(): IterableIterator<StructuredLaunchState
return structuredLaunchesBySessionId.values()
}
export function launchStateLifecycle(
state: StructuredLaunchState
): StructuredAgentSessionLaunchLifecycle {
if (state.cancelled || state.callers.outcome === 'cancelled') {
return 'cancelled'
}
if (state.callers.outcome === 'published') {
return 'published'
}
if (state.visibilityUnknown || state.callers.outcome === 'unknown') {
return 'visibility-unknown'
}
return state.callers.outcome === 'failed' ? 'failed' : 'pending'
}
export function getStructuredAgentSessionLaunchLifecycle(
worktreeId: string,
sessionId: string
@@ -249,6 +240,7 @@ function markStructuredAgentSessionLaunchCancelledInternal(
)
}
state.cancelled = true
forgetStructuredSessionSelection(sessionId, state.intent.target)
state.callers.outcome = 'cancelled'
// Tombstones remain durable authority; drop in-memory dead promises so bulk closes cannot retain them all session.
deleteStructuredLaunchStateIfCurrent(state)
@@ -76,6 +76,7 @@ vi.mock('@/lib/agent-catalog', () => ({
}))
import { retryStructuredAgentSessionLaunch } from '../../structured-agent-session-launch'
import { structuredLaunchOptionOverlay } from '../../structured-agent-session-launch-options'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
import {
resetStructuredAgentLaunchRegistryForTests,
@@ -192,10 +193,11 @@ describe('structured agent launch reload recovery', () => {
.mockResolvedValueOnce([publishedSnapshot(worktreeId, sessionId)])
expect(retryStructuredAgentSessionLaunch(worktreeId, sessionId)).toBe(true)
expect(getStructuredLaunchStateBySessionId(sessionId)?.selection).toMatchObject({
explicit: { model: 'action-model', fastMode: 'false' },
held: {}
const restored = getStructuredLaunchStateBySessionId(sessionId)!
expect(restored.selection).toMatchObject({
explicit: { model: 'action-model', fastMode: 'false' }
})
expect(structuredLaunchOptionOverlay(restored)).toEqual({})
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledWith(
@@ -13,7 +13,10 @@ import {
structuredLaunchIdentity,
type StructuredLaunchState
} from './structured-agent-session-launch-registry'
import { initialStructuredLaunchSelection } from '../../structured-agent-session-launch-options'
import {
initialStructuredLaunchSelection,
structuredLaunchSelectionController
} from '../../structured-agent-session-launch-options'
export function restorePersistedStructuredLaunchState(
worktreeId: string,
@@ -70,6 +73,7 @@ export function restorePersistedStructuredLaunchState(
}
callers.outcome = record.lifecycle === 'failed' ? 'failed' : 'unknown'
callers.failedAt = record.failedAt
structuredLaunchSelectionController(state)
setStructuredLaunchState(state)
return state
}
@@ -106,7 +106,7 @@ function setLaunch(
promise,
visibilityUnknown: false,
cancelled: false,
selection: { held: {} }
selection: {}
} satisfies StructuredLaunchState)
}
@@ -280,7 +280,7 @@ describe('structured launch cancellation retirement', () => {
promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }),
visibilityUnknown: false,
cancelled: false,
selection: { held: {} }
selection: {}
} satisfies StructuredLaunchState)
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -43,6 +43,7 @@ export type StructuredAgentSessionLaunchIntent = {
target: RuntimeClientTarget
agent: TuiAgent
params: StructuredAgentSessionCreateParams
sendSelectionSupport?: boolean
createMessageSupport?: boolean
createPrepared?: true
prepareCreate?: (hostTakesExplicitOptions: boolean) => void
@@ -171,6 +172,7 @@ export function restoreStructuredAgentSessionLaunchIntent(args: {
firstMessage?: StructuredAgentSessionFirstMessage
optionsMode?: 'replace-model-selection'
options?: Readonly<Record<string, string>>
sendSelectionSupport?: boolean
createMessageSupport?: boolean
}): StructuredAgentSessionLaunchIntent {
const state = useAppStore.getState()
@@ -9,6 +9,7 @@ import { newAgentLaunchRequestId } from './agent-launch-request-id'
export function registerActionEffortCreateTests(
mocks: {
settings: Record<string, unknown>
exactSupport: Mock
call: Mock<(target: unknown, method: string, params: unknown) => Promise<unknown>>
},
created: () => unknown
@@ -18,6 +19,7 @@ export function registerActionEffortCreateTests(
'%s action effort overlays remembered effort in create and yields to a held pick',
async (agent) => {
const model = agent === 'claude' ? 'claude-opus-5-5[1m]' : 'gpt-5.4'
mocks.exactSupport.mockResolvedValue(true)
mocks.settings = {
nativeChatSessionOptions: {
[agent]: { model, valuesByModel: { [model]: { effort: 'low' } } }
@@ -60,6 +62,7 @@ export function registerActionEffortCreateTests(
expect(params.firstMessage?.body.blocks).toEqual([{ type: 'text', text: 'opening text' }])
expect(params.optionsMode).toBeUndefined()
expect(params.options).toMatchObject({ effort: held ?? 'high' })
expect(params.firstMessage?.selection).toMatchObject({ effort: held ?? 'high' })
if (agent === 'claude') {
expect(params.options?.model).toBe(model)
}
@@ -1,3 +1,4 @@
import { AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities'
import type {
AgentSessionAttachResult,
AgentSessionMutationResult
@@ -12,6 +13,7 @@ import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-writ
import {
callStructuredAgentSession,
supportsStructuredAgentSessionCreateOptionsMode,
structuredAgentSessionHostSupports,
supportsStructuredAgentSessionCreateMessage
} from '@/runtime/structured-agent-session-client'
import type { StructuredAgentSessionCreateParams } from '../../../shared/structured-agent-session-create'
@@ -125,6 +127,10 @@ export async function launchStructuredAgentSession(
supported &&
intent.explicitLaunchOptions !== undefined &&
(await supportsStructuredAgentSessionCreateOptionsMode(intent.target))
intent.sendSelectionSupport = await structuredAgentSessionHostSupports(
intent.target,
AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY
)
} catch {
// Unknown capability stays on the compatible create path.
}
@@ -0,0 +1,276 @@
// @vitest-environment happy-dom
import { vi, type Mock } from 'vitest'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire'
import {
CreateIntentParams,
SetOptionParams,
SendParams
} from '../../../shared/rpc-contract/structured-agent-session-params'
import type { HostCreateSupport } from './structured-agent-session-host-admission'
type CreateMessageFixtureMocks = {
call: Mock<(target: unknown, method: string, params: unknown) => Promise<unknown>>
supports: Mock<() => Promise<boolean>>
supportsMode: Mock<() => Promise<boolean>>
exactSupport: Mock<() => Promise<boolean>>
admit: Mock<() => Promise<HostCreateSupport>>
inventory: Mock
settings: Record<string, unknown>
}
const mocks: CreateMessageFixtureMocks = vi.hoisted(() => {
const settings: Record<string, unknown> = {}
return {
call: vi.fn<(target: unknown, method: string, params: unknown) => Promise<unknown>>(),
supports: vi.fn<() => Promise<boolean>>(),
supportsMode: vi.fn<() => Promise<boolean>>(),
exactSupport: vi.fn<() => Promise<boolean>>(),
admit: vi.fn<() => Promise<HostCreateSupport>>(),
inventory: vi.fn(),
settings
}
})
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return layoutOwner.isMain
}
}))
// This machine's host closes the chat's tab when asked.
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn(async () => ({})) }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call,
supportsStructuredAgentSessionCreateOptionsMode: mocks.supportsMode,
supportsStructuredAgentSessionCreateMessage: mocks.supports,
structuredAgentSessionHostSupports: mocks.exactSupport
}))
vi.mock('./structured-agent-session-host-admission', () => ({
askHostCreateSupport: mocks.admit
}))
vi.mock('@/runtime/structured-session-tab-inventory', () => ({
readStructuredSessionTabInventory: mocks.inventory
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
runtimeEnvironmentCatalogHydrated: true,
settings: mocks.settings,
unifiedTabsByWorktree: {},
groupsByWorktree: {},
seedNativeChatLaunchDraft: vi.fn(),
clearNativeChatLaunchDraft: vi.fn()
}),
subscribe: () => () => {}
}
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/runtime/structured-chat-host-permission', () => ({
readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' })
}))
import { useAppStore } from '@/store'
import { registerStructuredSelectionCatalogStore } from './structured-agent-session-selection-controller'
import { resetStructuredAgentLaunchRegistryForTests } from './agent-session/launch/structured-agent-session-launch-registry'
import { resetStructuredAgentLaunchPersistenceForTests } from './agent-session/launch/structured-agent-session-launch-persistence'
import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender'
import { resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner'
import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots'
import { startStructuredAgentLaunch } from './structured-agent-session-launch'
import { newAgentLaunchRequestId } from './agent-launch-request-id'
import {
clearNativeChatDraftCacheForTests,
readNativeChatDraftCache
} from '@/components/native-chat/native-chat-draft-cache'
import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store'
export function resetCreateMessageFixture() {
const restore = registerStructuredSelectionCatalogStore({
read: useAppStore.getState,
subscribe: useAppStore.subscribe
})
vi.useFakeTimers()
vi.clearAllMocks()
layoutOwner.isMain = false
localStorage.clear()
resetStructuredAgentLaunchRegistryForTests()
resetStructuredAgentLaunchPersistenceForTests()
resetStructuredAgentSessionSendsForTests()
resetStructuredAgentSessionReadOwnersForTests()
clearNativeChatDraftCacheForTests()
mocks.supports.mockResolvedValue(true)
mocks.supportsMode.mockResolvedValue(true)
mocks.exactSupport.mockResolvedValue(false)
resetHostModelCatalogSnapshotsForTests()
mocks.admit.mockResolvedValue({ kind: 'admitted' })
mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create'))
return restore
}
export function cleanupCreateMessageFixture(restore?: () => void) {
resetStructuredAgentLaunchRegistryForTests()
restore?.()
resetStructuredAgentSessionSendsForTests()
resetStructuredAgentSessionReadOwnersForTests()
vi.useRealTimers()
}
function deferred<T>() {
return Promise.withResolvers<T>()
}
function createParams() {
return CreateIntentParams.parse(
mocks.call.mock.calls.find(([, method]) => method === 'agentSession.create')?.[2]
)
}
function submission(clientMessageId: string): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: 'a'.repeat(64),
dispatchState: 'pending',
handoverRecorded: true,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}
function created() {
const params = createParams()
const rows = params.firstMessage ? [submission(params.firstMessage.clientMessageId)] : []
const cursor = { epoch: 'epoch-1', sequence: rows.length }
const page: AgentSessionHistoryPage = {
sessionId: params.envelope.sessionId,
epoch: cursor.epoch,
fence: 1,
direction: 'tail',
items: [],
removedItemIds: [],
submissions: rows,
window: { oldest: null, newest: null, nextCursor: cursor },
liveCursor: cursor,
hasOlder: false,
hasNewer: false,
latestTurn: null
}
return {
ok: true,
replayed: false,
value: { sessionId: page.sessionId, fence: 1, page, unconfirmedClientMessageIds: [] }
}
}
const target = { kind: 'local' } as const
const start = () =>
startStructuredAgentLaunch('workspace-1', 'codex', {
requestId: newAgentLaunchRequestId(),
executionHostId: 'local',
prompt: 'opening text'
})
const draft = (sessionId: string) =>
readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(sessionId))
// An older host (no create-message) applies an action's explicit options one setOption at a time.
function startLegacyActionLaunch(
launchOptions: Record<string, string>,
refusedKey: string,
refusal: Record<string, unknown> | Error
) {
mocks.supports.mockResolvedValue(false)
mocks.call.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.create') {
return created()
}
if (method === 'agentSession.setOption') {
const key = params && typeof params === 'object' && 'key' in params ? params.key : null
if (key === refusedKey && refusal instanceof Error) {
throw refusal
}
return key === refusedKey
? { ok: false, refusal }
: { ok: true, value: { options: { model: launchOptions.model } } }
}
const send = SendParams.parse(params)
const clientMessageId = send.envelope.clientOperationId
return { ok: true, value: { clientMessageId, submission: submission(clientMessageId) } }
})
const launch = startStructuredAgentLaunch('workspace-1', 'codex', {
requestId: newAgentLaunchRequestId(),
executionHostId: 'local',
prompt: 'opening text',
launchOptions
})
mocks.inventory.mockResolvedValue([
{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] }
])
return launch
}
export {
mocks,
layoutOwner,
deferred,
createParams,
submission,
created,
target,
start,
draft,
startLegacyActionLaunch
}
export function legacyAction(
explicit: Record<string, string> | undefined,
write: (key: string, value: string) => Promise<unknown>,
executionHostId: ExecutionHostId = 'local',
agent: 'claude' | 'codex' = 'codex'
) {
const create = deferred<unknown>()
mocks.supports.mockResolvedValue(false)
mocks.call.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.create') {
return create.promise
}
if (method === 'agentSession.setOption') {
const { key, value } = SetOptionParams.parse(params)
return write(key, value)
}
if (method === 'agentSession.history') {
return { ok: true, page: { ...created().value.page, fence: 2 } }
}
const send = SendParams.parse(params)
return {
ok: true,
value: {
clientMessageId: send.envelope.clientOperationId,
submission: submission(send.envelope.clientOperationId)
}
}
})
const launch = startStructuredAgentLaunch('workspace-1', agent, {
requestId: newAgentLaunchRequestId(),
executionHostId,
prompt: 'opening text',
...(explicit ? { launchOptions: explicit } : {}),
...(executionHostId !== 'local' ? { hostSeedOptions: { model: 'gpt-5.5' } } : {})
})
mocks.inventory.mockResolvedValue([
{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] }
])
return { launch, create }
}
export const acceptedOption = (key: string, value: string) => ({
ok: true,
value: { key, value, options: { [key]: value } }
})
export const refusedOption = {
ok: false,
refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } }
}
export const optionCalls = () =>
mocks.call.mock.calls
.filter(([, method]) => method === 'agentSession.setOption')
.map(([, , params]) => SetOptionParams.parse(params))
@@ -1,7 +1,21 @@
// @vitest-environment happy-dom
import {
mocks,
resetCreateMessageFixture,
cleanupCreateMessageFixture,
layoutOwner,
deferred,
createParams,
submission,
created,
target,
start,
draft,
startLegacyActionLaunch
} from './structured-agent-session-launch-create-message.test-fixture'
import { useAppStore } from '@/store'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire'
import {
CreateIntentParams,
SendParams
@@ -9,54 +23,6 @@ import {
import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation'
import { registerActionEffortCreateTests } from './structured-agent-session-launch-action-effort.test-fixture'
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
import type { HostCreateSupport } from './structured-agent-session-host-admission'
const mocks = vi.hoisted(() => {
const settings: Record<string, unknown> = {}
return {
call: vi.fn<(target: unknown, method: string, params: unknown) => Promise<unknown>>(),
supports: vi.fn<() => Promise<boolean>>(),
supportsMode: vi.fn<() => Promise<boolean>>(),
admit: vi.fn<() => Promise<HostCreateSupport>>(),
inventory: vi.fn(),
settings
}
})
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return layoutOwner.isMain
}
}))
// This machine's host closes the chat's tab when asked.
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn(async () => ({})) }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call,
supportsStructuredAgentSessionCreateOptionsMode: mocks.supportsMode,
supportsStructuredAgentSessionCreateMessage: mocks.supports
}))
vi.mock('./structured-agent-session-host-admission', () => ({
askHostCreateSupport: mocks.admit
}))
vi.mock('@/runtime/structured-session-tab-inventory', () => ({
readStructuredSessionTabInventory: mocks.inventory
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
settings: mocks.settings,
unifiedTabsByWorktree: {},
groupsByWorktree: {},
seedNativeChatLaunchDraft: vi.fn(),
clearNativeChatLaunchDraft: vi.fn()
}),
subscribe: () => () => {}
}
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/runtime/structured-chat-host-permission', () => ({
readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' })
}))
import {
startStructuredAgentLaunch,
@@ -64,7 +30,17 @@ import {
retryStructuredAgentSessionLaunch,
getStructuredAgentSessionLaunchLifecycle
} from './structured-agent-session-launch'
import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options'
import {
registerStructuredSelectionCatalogStore,
structuredSessionSelectionController,
forgetStructuredSessionSelection
} from './structured-agent-session-selection-controller'
import { setRuntimeEnvironmentConnectionGenerationForTests } from '@/store/slices/runtime-status-connection-generation'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import {
holdStructuredAgentSessionLaunchOption,
structuredLaunchOptionOverlay
} from './structured-agent-session-launch-options'
import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots'
import {
resetStructuredAgentLaunchRegistryForTests,
@@ -79,8 +55,7 @@ import { markStructuredAgentSessionLaunchPublished } from './agent-session/launc
import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors'
import {
getStructuredAgentSessionReadOwner,
findStructuredAgentSessionReadOwner,
resetStructuredAgentSessionReadOwnersForTests
findStructuredAgentSessionReadOwner
} from '@/components/native-chat/structured-agent-session-read-owner'
import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender'
import {
@@ -88,132 +63,64 @@ import {
getStructuredAgentSessionSendNotice
} from '@/components/native-chat/structured-agent-session-pending-sends'
import { structuredAgentSessionStopControl } from '../../../shared/structured-agent-session-stop-control'
import {
clearNativeChatDraftCacheForTests,
readNativeChatDraftCache,
writeNativeChatDraftCache
} from '@/components/native-chat/native-chat-draft-cache'
import { writeNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache'
import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store'
function deferred<T>() {
return Promise.withResolvers<T>()
}
function createParams() {
return CreateIntentParams.parse(
mocks.call.mock.calls.find(([, method]) => method === 'agentSession.create')?.[2]
)
}
function submission(clientMessageId: string): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: 'a'.repeat(64),
dispatchState: 'pending',
handoverRecorded: true,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}
function created() {
const params = createParams()
const rows = params.firstMessage ? [submission(params.firstMessage.clientMessageId)] : []
const cursor = { epoch: 'epoch-1', sequence: rows.length }
const page: AgentSessionHistoryPage = {
sessionId: params.envelope.sessionId,
epoch: cursor.epoch,
fence: 1,
direction: 'tail',
items: [],
removedItemIds: [],
submissions: rows,
window: { oldest: null, newest: null, nextCursor: cursor },
liveCursor: cursor,
hasOlder: false,
hasNewer: false,
latestTurn: null
}
return {
ok: true,
replayed: false,
value: { sessionId: page.sessionId, fence: 1, page, unconfirmedClientMessageIds: [] }
}
}
const target = { kind: 'local' } as const
const start = () =>
startStructuredAgentLaunch('workspace-1', 'codex', {
requestId: newAgentLaunchRequestId(),
executionHostId: 'local',
prompt: 'opening text'
})
const draft = (sessionId: string) =>
readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(sessionId))
// An older host (no create-message) applies an action's explicit options one setOption at a time.
function startLegacyActionLaunch(
launchOptions: Record<string, string>,
refusedKey: string,
refusal: Record<string, unknown>
) {
mocks.supports.mockResolvedValue(false)
mocks.call.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.create') {
return created()
}
if (method === 'agentSession.setOption') {
const key = params && typeof params === 'object' && 'key' in params ? params.key : null
return key === refusedKey
? { ok: false, refusal }
: { ok: true, value: { options: { model: launchOptions.model } } }
}
const send = SendParams.parse(params)
const clientMessageId = send.envelope.clientOperationId
return { ok: true, value: { clientMessageId, submission: submission(clientMessageId) } }
})
const launch = startStructuredAgentLaunch('workspace-1', 'codex', {
requestId: newAgentLaunchRequestId(),
executionHostId: 'local',
prompt: 'opening text',
launchOptions
})
mocks.inventory.mockResolvedValue([
{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] }
])
return launch
}
let restoreCatalogStore: (() => void) | undefined
describe('the first message owned by create', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
layoutOwner.isMain = false
localStorage.clear()
resetStructuredAgentLaunchRegistryForTests()
resetStructuredAgentLaunchPersistenceForTests()
resetStructuredAgentSessionSendsForTests()
resetStructuredAgentSessionReadOwnersForTests()
clearNativeChatDraftCacheForTests()
mocks.supports.mockResolvedValue(true)
mocks.supportsMode.mockResolvedValue(true)
mocks.admit.mockResolvedValue({ kind: 'admitted' })
mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create'))
restoreCatalogStore = resetCreateMessageFixture()
})
afterEach(() => {
resetStructuredAgentSessionSendsForTests()
resetStructuredAgentSessionReadOwnersForTests()
vi.useRealTimers()
cleanupCreateMessageFixture(restoreCatalogStore)
})
it('requires registered authority before inserting a remote selection owner', () => {
restoreCatalogStore?.()
const remote = { kind: 'environment', environmentId: 'uncomposed-test-store' } as const
expect(() => structuredSessionSelectionController(remote, 'uncomposed', 'codex')).toThrow(
'must be registered'
)
const restore = registerStructuredSelectionCatalogStore({
read: useAppStore.getState,
subscribe: useAppStore.subscribe
})
try {
const owner = structuredSessionSelectionController(remote, 'uncomposed', 'codex')
expect(structuredSessionSelectionController(remote, 'uncomposed', 'codex')).toBe(owner)
} finally {
forgetStructuredSessionSelection('uncomposed', remote)
restore()
}
})
it('freezes the message and held options in create and publishes without another read or send', async () => {
mocks.exactSupport.mockResolvedValue(true)
recordHostModelCatalogSnapshot(target, 'codex', null, {
origin: 'probe',
fetchedAt: 1,
defaultHoldsInEveryWorkspace: true,
models: [
{
id: 'picked-model',
label: 'Picked',
isDefault: false,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
]
})
const create = deferred<unknown>()
mocks.call.mockReturnValue(create.promise)
const launch = start()
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'picked-model')
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high')
await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
const params = createParams()
expect(params.firstMessage?.body.blocks).toEqual([{ type: 'text', text: 'opening text' }])
expect(params.options).toEqual({ model: 'picked-model' })
expect(params.options).toEqual({ model: 'picked-model', effort: 'high' })
expect(params.firstMessage?.selection).toEqual({ model: 'picked-model', effort: 'high' })
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionCreateFingerprint({ sessionId: launch.sessionId, ...params })
)
@@ -254,6 +161,7 @@ describe('the first message owned by create', () => {
])(
"keeps a remembered model the host's saved list lacks, %s: create sends the literal saved choice",
async (_kind, resume) => {
mocks.exactSupport.mockResolvedValue(true)
mocks.settings = {
nativeChatSessionOptions: {
claude: { model: 'claude-gone' }
@@ -275,6 +183,7 @@ describe('the first message owned by create', () => {
})
await launch.launchResult
expect(createParams().options?.model).toBe('claude-gone')
expect(createParams().firstMessage?.selection).toEqual({ model: 'claude-gone' })
} finally {
mocks.settings = {}
}
@@ -302,7 +211,9 @@ describe('the first message owned by create', () => {
expect(readStructuredAgentLaunchRecord(launch.sessionId)?.explicitLaunchOptions).toEqual({
model: 'unlisted-action-model'
})
expect(getStructuredLaunchStateBySessionId(launch.sessionId)?.selection.held).toEqual({})
expect(
structuredLaunchOptionOverlay(getStructuredLaunchStateBySessionId(launch.sessionId)!)
).toEqual({})
await launch.launchResult
expect(createParams().optionsMode).toBe('replace-model-selection')
expect(createParams().options).toEqual({
@@ -425,6 +336,7 @@ describe('the first message owned by create', () => {
const create = deferred<unknown>()
mocks.call.mockReturnValue(create.promise)
const launch = start()
const held = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'gpt-5.4')!
const state = getStructuredLaunchStateBySessionId(launch.sessionId)
if (state) {
state.intent.surfaceAddress = { worktreeId: 'workspace-1', precedingTabIds: [] }
@@ -446,6 +358,7 @@ describe('the first message owned by create', () => {
expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create'])
expect(getStructuredAgentSessionPendingSends(launch.sessionId)).toEqual([])
expect(findStructuredAgentSessionReadOwner(launch.sessionId, target)).toBeUndefined()
await expect(held).resolves.toEqual({ kind: 'superseded' })
})
it('returns text at 30 seconds and a late create reply cannot send it again', async () => {
@@ -524,14 +437,24 @@ describe('the first message owned by create', () => {
expect(draft(launch.sessionId)).toBe('')
})
it.each(['model', 'effort'])(
'keeps the legacy chat and text when required %s is refused, with no fresh Retry',
async (refused) => {
const launch = startLegacyActionLaunch({ model: 'action-model', effort: 'high' }, refused, {
code: 'agent_session_operation_invalid',
details: { reason: 'optionRejected' },
message: 'unavailable'
})
it.each([
{ key: 'model', unknown: false },
{ key: 'effort', unknown: false },
{ key: 'model', unknown: true }
])(
'keeps the legacy chat and text when required input fails: %j',
async ({ key: refused, unknown }) => {
const launch = startLegacyActionLaunch(
{ model: 'action-model', effort: 'high' },
refused,
unknown
? new Error('runtime_unavailable')
: {
code: 'agent_session_operation_invalid',
details: { reason: 'optionRejected' },
message: 'unavailable'
}
)
const receipt = await launch.launchResult
expect(receipt).toMatchObject({ sessionId: launch.sessionId, configurationFailure: {} })
await expect(launch.promptDeliveryResult).resolves.toMatchObject({
@@ -633,15 +556,17 @@ describe('the first message owned by create', () => {
).toBeNull()
})
it('publishes while a later option write still waits for the agent', async () => {
it('enqueues two late picks before modern publication without waiting for acceptance', async () => {
const create = deferred<unknown>()
const option = deferred<unknown>()
const replies = [deferred<unknown>(), deferred<unknown>()]
let writes = 0
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.setOption' ? option.promise : create.promise
method === 'agentSession.setOption' ? replies[writes++]!.promise : create.promise
)
const launch = start()
await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'later-pick')
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'sonnet')
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high')
create.resolve(created())
let published = false
void launch.launchResult.then(() => {
@@ -652,9 +577,14 @@ describe('the first message owned by create', () => {
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true })
expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([
'agentSession.create',
'agentSession.setOption',
'agentSession.setOption'
])
option.resolve({ ok: true, value: { selected: 'later-pick' } })
const controller = structuredSessionSelectionController(target, launch.sessionId, 'codex')
expect(controller.pending()).toHaveLength(2)
replies[1]!.resolve({ ok: true, value: { options: { effort: 'high' } } })
replies[0]!.resolve({ ok: true, value: { options: { model: 'sonnet' } } })
await vi.waitFor(() => expect(controller.captureRead()).toHaveLength(2))
})
it('recovers a reload by replaying the frozen create when the old row is outside history', async () => {
@@ -707,13 +637,35 @@ describe('the first message owned by create', () => {
it('allows Retry after the deadline while the original create never answers', async () => {
const create = deferred<unknown>()
mocks.call.mockReturnValue(create.promise)
const launch = start()
const paired = { kind: 'environment', environmentId: 'frozen-generation' } as const
replaceRuntimeEnvironmentRevisions([{ id: paired.environmentId, createdAt: 1 }])
const launch = startStructuredAgentLaunch('workspace-1', 'codex', {
requestId: newAgentLaunchRequestId(),
executionHostId: 'runtime:frozen-generation',
hostSeedOptions: { model: 'gpt-5.5' },
prompt: 'opening text'
})
const first = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'gpt-5.4')!
await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
const original = createParams()
const owner = structuredSessionSelectionController(paired, launch.sessionId, 'codex')
const carried = owner.pending()[0]!
await vi.advanceTimersByTimeAsync(30_000)
await launch.promptDeliveryResult
setRuntimeEnvironmentConnectionGenerationForTests(paired.environmentId, 2)
expect(structuredSessionSelectionController(paired, launch.sessionId, 'codex')).toBe(owner)
const middle = holdStructuredAgentSessionLaunchOption(
launch.sessionId,
'model',
'gpt-5.3-codex'
)!
const newest = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'gpt-5.4')!
await expect(middle).resolves.toEqual({ kind: 'superseded' })
const applied = deferred<unknown>()
mocks.inventory.mockResolvedValue([])
mocks.call.mockImplementation(async () => created())
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.setOption' ? applied.promise : Promise.resolve(created())
)
expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true)
await vi.waitFor(() =>
expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined()
@@ -721,8 +673,16 @@ describe('the first message owned by create', () => {
const creates = mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create')
expect(creates).toHaveLength(2)
expect(creates[1]?.[2]).toEqual(original)
await expect(first).resolves.toMatchObject({ kind: 'accepted' })
expect(carried.outcome?.kind).toBe('accepted')
expect(owner.pending()).toHaveLength(1)
expect(owner.pending()[0]).not.toBe(carried)
applied.resolve({ ok: true, value: { options: { model: 'gpt-5.4' } } })
await expect(newest).resolves.toMatchObject({ kind: 'accepted' })
expect(owner.captureRead()).toHaveLength(2)
expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')).toEqual([])
expect(draft(launch.sessionId)).toBe('opening text')
replaceRuntimeEnvironmentRevisions([])
})
it.each([
@@ -13,6 +13,7 @@ import {
} from './structured-agent-session-launch-prompt'
import type { StructuredAgentLaunchReceipt } from './agent-session/launch/structured-agent-session-launch-recovery'
import {
structuredAgentSessionMessageSelection,
structuredAgentSessionOptionView,
structuredAgentSessionSendSelection
} from '../../../shared/structured-agent-session-option-view'
@@ -21,6 +22,7 @@ import {
applyStructuredLaunchHeldOptions,
applyStructuredLaunchExplicitOptions,
holdStructuredLaunchCreatePicks,
structuredLaunchOptionOverlay,
settleStructuredLaunchCreateOptions
} from './structured-agent-session-launch-options'
import { structuredAgentSessionDrawnValues } from '../../../shared/structured-agent-session-option-snapshot'
@@ -51,14 +53,16 @@ export function prepareStructuredLaunchCreateMessage(
return
}
const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target)
const requested = structuredLaunchCreateOptions(state)
const { options: requested, selection } = structuredLaunchCreateOptions(state)
const options =
requested && state.selection.explicit && !optionsMode
? structuredLaunchCompatibleOptions(intent.agent, requested)
: requested
const params = {
...intent.params,
...(firstMessage ? { firstMessage } : {}),
...(firstMessage
? { firstMessage: { ...firstMessage, ...(selection !== undefined ? { selection } : {}) } }
: {}),
...(options ? { options } : {}),
...(optionsMode ? { optionsMode } : {})
}
@@ -90,38 +94,51 @@ function omitStructuredLaunchPermissionMode(state: StructuredLaunchState): void
}
/** The first turn uses held picks over the action's choices over the chat's shown selection. */
function structuredLaunchCreateOptions(
state: StructuredLaunchState
): Readonly<Record<string, string>> | undefined {
const { intent, selection } = state
const requested = structuredLaunchRequestedOptions(selection)
function structuredLaunchCreateOptions(state: StructuredLaunchState): {
options?: Readonly<Record<string, string>>
selection?: Readonly<Record<string, string>>
} {
const { intent } = state
const overlay = structuredLaunchOptionOverlay(state)
const requested = structuredLaunchRequestedOptions({ ...state.selection, held: overlay })
const firstFrame = firstFrameOptionState(intent.agent, intent.target, {
newLaunch: !intent.params.resumeFrom,
worktree: intent.params.worktree,
seedsModel: intent.seedOptions?.model !== undefined
})
// Create has always carried options, so any host takes what the chat shows.
const view = structuredAgentSessionOptionView(firstFrame, intent.seedOptions, selection.held)
const view = structuredAgentSessionOptionView(
firstFrame,
intent.seedOptions,
{ ...requested, ...overlay },
state.selection.explicit
)
const drawn = structuredAgentSessionDrawnValues(view)
// Each held value as the picker draws it; the held picks themselves stay as the user made them.
const held = Object.fromEntries(
Object.entries(selection.held).map(([id, encoded]) => {
Object.entries(overlay).map(([id, encoded]) => {
const value = id === 'model' ? undefined : drawn[id]?.value
const shown = value === undefined ? null : encodeStructuredAgentSessionOptionValue(id, value)
return [id, shown ?? encoded]
})
)
holdStructuredLaunchCreatePicks(state, selection.held)
if (requested?.model !== undefined) {
// The view draws under a re-picked model only; an action's own model keeps picks as made.
return requested.model === selection.held.model ? { ...requested, ...held } : requested
holdStructuredLaunchCreatePicks(state)
const options =
requested?.model !== undefined
? requested.model === overlay.model
? { ...requested, ...held }
: requested
: {
...structuredAgentSessionSendSelection(view),
...requested,
...held
}
return {
...(Object.keys(options).length ? { options } : {}),
...(intent.sendSelectionSupport
? { selection: structuredAgentSessionMessageSelection(view) }
: {})
}
const options = {
...structuredAgentSessionSendSelection(view),
...requested,
...held
}
return Object.keys(options).length ? options : undefined
}
/** Later picks use the normal option path without holding publication behind startup. */
@@ -133,7 +150,7 @@ export function publishStructuredLaunchCreateOptions(
return applyStructuredLaunchExplicitOptions(state, receipt)
}
settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {})
void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => {
void applyStructuredLaunchHeldOptions(state, receipt, false).catch((error: unknown) => {
console.warn('[native-chat] applying a launch option failed', error)
})
return receipt
@@ -0,0 +1,19 @@
import type {
StructuredLaunchState,
StructuredAgentSessionLaunchLifecycle
} from './agent-session/launch/structured-agent-session-launch-registry'
export function launchStateLifecycle(
state: StructuredLaunchState
): StructuredAgentSessionLaunchLifecycle {
if (state.cancelled || state.callers.outcome === 'cancelled') {
return 'cancelled'
}
if (state.callers.outcome === 'published') {
return 'published'
}
if (state.visibilityUnknown || state.callers.outcome === 'unknown') {
return 'visibility-unknown'
}
return state.callers.outcome === 'failed' ? 'failed' : 'pending'
}
@@ -1,7 +1,8 @@
import { useAppStore } from '@/store'
// @vitest-environment happy-dom
import type * as LaunchErrors from '@/lib/structured-agent-session-launch-errors'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
@@ -13,12 +14,17 @@ type CallParams = {
}
const mocks = vi.hoisted(() => ({
settingsWrite: vi.fn(async () => {}),
call: vi.fn<(target: unknown, method: string, params: CallParams) => Promise<unknown>>(),
createIntent: vi.fn(),
retryIntent: vi.fn(),
launch: vi.fn()
}))
vi.mock('@/components/native-chat/native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: mocks.settingsWrite
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
const launchErrorMocks = vi.hoisted(() => {
@@ -58,6 +64,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
runtimeEnvironmentCatalogHydrated: true,
unifiedTabsByWorktree: {},
seedNativeChatLaunchDraft: vi.fn(),
clearNativeChatLaunchDraft: vi.fn()
@@ -84,15 +91,22 @@ import {
startStructuredAgentLaunch
} from './structured-agent-session-launch'
import {
applyStructuredLaunchHeldOptions,
getStructuredAgentSessionLaunchSelection,
holdStructuredAgentSessionLaunchOption
} from './structured-agent-session-launch-options'
import { resetStructuredAgentLaunchPersistenceForTests } from './agent-session/launch/structured-agent-session-launch-persistence'
import {
getStructuredLaunchStateBySessionId,
resetStructuredAgentLaunchRegistryForTests,
subscribeStructuredAgentLaunchStatus
} from './agent-session/launch/structured-agent-session-launch-registry'
import { markStructuredAgentSessionLaunchPublished } from './agent-session/launch/structured-agent-session-launch-publication'
import {
registerStructuredSelectionCatalogStore,
structuredSessionSelectionController,
forgetStructuredSessionSelection
} from './structured-agent-session-selection-controller'
const WORKTREE_ID = 'wt-1'
const SESSION_ID = 'session-1'
@@ -172,10 +186,21 @@ async function settle(): Promise<void> {
}
}
let restoreCatalogStore: (() => void) | undefined
afterEach(() => {
resetStructuredAgentLaunchRegistryForTests()
forgetStructuredSessionSelection(SESSION_ID)
restoreCatalogStore?.()
})
describe('picks made while a chat launches', () => {
let setOptionReplies: Deferred<unknown>[]
beforeEach(() => {
restoreCatalogStore = registerStructuredSelectionCatalogStore({
read: useAppStore.getState,
subscribe: useAppStore.subscribe
})
forgetStructuredSessionSelection(SESSION_ID)
vi.clearAllMocks()
localStorage.clear()
resetStructuredAgentLaunchPersistenceForTests()
@@ -217,7 +242,8 @@ describe('picks made while a chat launches', () => {
markStructuredAgentSessionLaunchPublished(WORKTREE_ID, SESSION_ID, 'local')
expect(lifecycle()).toBe('pending')
expect(mutations()).toEqual([
{ method: 'agentSession.setOption', fence: 7, key: 'model', value: 'gpt-picked' }
{ method: 'agentSession.setOption', fence: 7, key: 'model', value: 'gpt-picked' },
{ method: 'agentSession.setOption', fence: 7, key: 'effort', value: 'high' }
])
setOptionReplies[0]!.resolve({
ok: true,
@@ -257,27 +283,21 @@ describe('picks made while a chat launches', () => {
expect(mutations().map((mutation) => mutation.value)).toEqual(['gpt-picked', 'gpt-repicked'])
})
it('sends held Fast with the picked model even when its catalog omits that tier', async () => {
it('keeps a held tier across a model pick whose catalog does not offer it', async () => {
const created = deferred<{ sessionId: string; fence: number }>()
mocks.launch.mockReturnValue(created.promise)
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-tier' })
const tierPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'serviceTier', 'priority')
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-nofast')
expect(getStructuredAgentSessionLaunchSelection(SESSION_ID)?.held).toEqual({
model: 'gpt-nofast',
serviceTier: 'priority'
})
created.resolve({ sessionId: SESSION_ID, fence: 3 })
await settle()
setOptionReplies[0]!.resolve(ACCEPTED)
await settle()
setOptionReplies[1]!.resolve({ ok: true, value: { options: { serviceTier: 'priority' } } })
expect(setOptionReplies).toHaveLength(2)
for (const reply of setOptionReplies) {
reply.resolve(ACCEPTED)
}
await expect(tierPick).resolves.toMatchObject({ kind: 'accepted' })
await launch.launchResult
await expect(tierPick).resolves.toEqual({
kind: 'accepted',
options: { serviceTier: 'priority' }
})
expect(mutations().map(({ key, value }) => [key, value])).toEqual([
['model', 'gpt-nofast'],
['serviceTier', 'priority']
@@ -362,8 +382,7 @@ describe('picks made while a chat launches', () => {
expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID)).toBe(true)
expect(getStructuredAgentSessionLaunchSelection(SESSION_ID)).toEqual({
seed: { model: 'gpt-saved-since' },
held: { model: 'gpt-picked' }
seed: { model: 'gpt-saved-since' }
})
await settle()
expect(mutations()).toEqual([
@@ -377,7 +396,13 @@ describe('picks made while a chat launches', () => {
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-6' })
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked')
const otherHost = { kind: 'environment', environmentId: 'surviving-held-chat' } as const
const survivor = structuredSessionSelectionController(otherHost, SESSION_ID, 'codex')
survivor.issue('model', 'other-host-model', null)
cancelStructuredAgentLaunch(WORKTREE_ID, SESSION_ID)
expect(structuredSessionSelectionController(otherHost, SESSION_ID, 'codex')).toBe(survivor)
expect(survivor.overlay()).toEqual({ model: 'other-host-model' })
forgetStructuredSessionSelection(SESSION_ID, otherHost)
created.resolve({ sessionId: SESSION_ID, fence: 1 })
await settle()
@@ -385,22 +410,67 @@ describe('picks made while a chat launches', () => {
expect(holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-late')).toBeNull()
})
it('folds an accepted pick into what the launch reports it runs', async () => {
mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 })
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-7' })
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high')
await settle()
const seen: unknown[] = []
subscribeStructuredAgentLaunchStatus(() =>
seen.push(getStructuredAgentSessionLaunchSelection(SESSION_ID))
)
setOptionReplies[0]!.resolve({
ok: true,
value: { key: 'effort', value: 'high', options: { model: 'gpt-seeded', effort: 'high' } }
})
await settle()
expect(seen[0]).toEqual({ seed: { model: 'gpt-seeded', effort: 'high' }, held: {} })
expect(lifecycle()).toBeNull()
it('keeps held issue and settlement off the global registry serializer at N and 10N', async () => {
for (const count of [10, 100]) {
mocks.launch.mockReturnValue(new Promise(() => {}))
for (let index = 0; index < count; index++) {
const sessionId = `perf-held-${index}`
const intent = {
...launchIntent({ model: 'gpt-seeded' }),
sessionId,
worktreeId: `perf-worktree-${index}`,
params: {
...launchIntent().params,
envelope: { ...launchIntent().params.envelope, sessionId }
}
}
mocks.createIntent.mockReturnValueOnce(intent)
startStructuredAgentLaunch(intent.worktreeId, 'codex', { requestId: sessionId })
}
const sessionId = 'perf-held-0'
const state = getStructuredLaunchStateBySessionId(sessionId)!
const selection = state.selection
const seed = selection.seed
const changed = vi.fn()
const global = vi.fn()
const unsubscribe = subscribeStructuredAgentLaunchStatus(global)
const controller = structuredSessionSelectionController(
state.intent.target,
sessionId,
'codex'
)
const stop = controller.subscribe(changed)
const storage = vi.spyOn(Storage.prototype, 'setItem')
const pick = holdStructuredAgentSessionLaunchOption(sessionId, 'model', 'gpt-picked')
await applyStructuredLaunchHeldOptions(state, { sessionId, fence: 1 }, false)
setOptionReplies.at(-1)!.resolve({ ok: true, value: { key: 'model', value: 'gpt-picked' } })
await expect(pick).resolves.toEqual({ kind: 'accepted', options: { model: 'gpt-picked' } })
await settle()
expect(state.selection).toBe(selection)
expect(state.selection.seed).toBe(seed)
expect(seed).toEqual({ model: 'gpt-seeded' })
expect(global).not.toHaveBeenCalled()
expect(storage).not.toHaveBeenCalled()
expect(changed).toHaveBeenCalledTimes(3)
expect(mocks.settingsWrite).toHaveBeenLastCalledWith(state.intent.target, {
type: 'apply-picks',
agent: 'codex',
picks: [{ modelId: 'gpt-picked', optionId: 'model', value: 'gpt-picked' }]
})
console.info(
'selection-held-notifier-work',
JSON.stringify({
pendingLaunches: count,
globalNotifications: 0,
serializerWrites: 0,
scopedNotifications: changed.mock.calls.length
})
)
storage.mockRestore()
stop()
unsubscribe()
resetStructuredAgentLaunchRegistryForTests()
resetStructuredAgentLaunchPersistenceForTests()
}
})
})
@@ -1,13 +1,21 @@
import {
createLegacyPrerequisites,
type LegacyPrerequisites
} from './structured-agent-session-launch-prerequisites'
import { useSyncExternalStore } from 'react'
import { structuredSessionSelectionController } from './structured-agent-session-selection-controller'
import type {
SelectionOperation,
SelectionOutcome
} from '../../../shared/structured-agent-session-selection-operations'
import {
structuredAgentSessionOptionPicks,
type StructuredAgentSessionOptionState
} from '../../../shared/structured-agent-session-options'
import type {
AgentSessionMutationResult,
AgentSessionOptionResult
} from '../../../shared/agent-session-wire'
import {
STRUCTURED_LAUNCH_OPTION_IDS,
structuredLaunchCompatibleOptions,
structuredLaunchRequestedOptions
} from '../../../shared/native-chat/input/native-chat-session-option-defaults'
import {
createStructuredAgentSessionOperationId,
structuredAgentSessionPayloadFingerprint
@@ -15,13 +23,9 @@ import {
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
StructuredAgentSessionLaunchCancelledError,
type StructuredAgentLaunchReceipt
} from './agent-session/launch/structured-agent-session-launch-recovery'
import type { StructuredAgentLaunchReceipt } from './agent-session/launch/structured-agent-session-launch-recovery'
import {
getStructuredLaunchStateBySessionId,
notifyStructuredLaunchListeners,
subscribeStructuredAgentLaunchStatus,
type StructuredLaunchState
} from './agent-session/launch/structured-agent-session-launch-registry'
@@ -35,12 +39,10 @@ import type { StructuredAgentSessionLaunchIntent } from './launch-structured-age
/** The options a launch starts with, replaced whole so readers can compare by identity. */
export type StructuredLaunchSelection = {
/** What the host runs as far as the launch knows: the create seed, then each accepted pick. */
/** The immutable create seed, until a queried host answer replaces it. */
seed?: Readonly<Record<string, string>>
/** Read-only projection of durable action input; held contains only subsequent edits. */
/** Immutable action input, separate from later picker operations. */
explicit?: Readonly<Record<string, string>>
/** Encoded picks made before publish; the launch applies them before it publishes. */
held: Readonly<Record<string, string>>
/** A paired server has not yet said what create seeds, so nothing can be picked against it. */
seedPending?: true
}
@@ -51,34 +53,44 @@ export function initialStructuredLaunchSelection(
return {
seed: intent.seedOptions,
...(intent.explicitLaunchOptions ? { explicit: intent.explicitLaunchOptions } : {}),
held: {},
...(intent.target.kind !== 'local' && !intent.seedOptions ? { seedPending: true } : {})
}
}
export type StructuredLaunchOptionOutcome =
| { kind: 'accepted'; options: Readonly<Record<string, string>> }
| { kind: 'refused'; failure: AgentSessionWriteFailure }
| { kind: 'superseded' }
export type StructuredLaunchOptionOutcome = SelectionOutcome
type OptionReply = (outcome: StructuredLaunchOptionOutcome) => void
const optionReplies = new WeakMap<StructuredLaunchState, Map<string, OptionReply>>()
function repliesFor(state: StructuredLaunchState): Map<string, OptionReply> {
let replies = optionReplies.get(state)
if (!replies) {
replies = new Map()
optionReplies.set(state, replies)
const launchControllers = new WeakMap<
StructuredLaunchState,
ReturnType<typeof structuredSessionSelectionController>
>()
export const structuredLaunchSelectionController = (state: StructuredLaunchState) => {
let controller = launchControllers.get(state)
if (!controller) {
controller = structuredSessionSelectionController(
state.intent.target,
state.intent.sessionId,
state.intent.agent
)
launchControllers.set(state, controller)
}
return replies
return controller
}
const selectionController = structuredLaunchSelectionController
const receipts = new WeakMap<StructuredLaunchState, StructuredAgentLaunchReceipt>()
/** Create and the attached view read the same finite operation overlay. */
export function structuredLaunchOptionOverlay(
state: StructuredLaunchState
): Record<string, string> {
return selectionController(state).overlay()
}
/** Hold picks only before publication; published/missing launches use the normal session option path. */
export function holdStructuredAgentSessionLaunchOption(
sessionId: string,
id: string,
encoded: string
encoded: string,
/** The picker's list, under which a model pick keeps only the held values the model offers. */
picker?: StructuredAgentSessionOptionState
): Promise<StructuredLaunchOptionOutcome> | null {
const state = getStructuredLaunchStateBySessionId(sessionId)
if (!state || state.cancelled || state.callers.outcome === 'published') {
@@ -87,173 +99,166 @@ export function holdStructuredAgentSessionLaunchOption(
if (state.selection.seedPending) {
return null
}
const replies = repliesFor(state)
const held: Record<string, string> = {}
// Each held value is the chat's own: a pick supersedes an earlier pick of the same option.
for (const [key, value] of Object.entries(state.selection.held)) {
if (key !== id) {
held[key] = value
} else {
replies.get(key)?.({ kind: 'superseded' })
replies.delete(key)
}
const model = picker
? (structuredAgentSessionOptionPicks(picker, { [id]: encoded })[0]?.modelId ?? null)
: (state.selection.seed?.model ?? null)
const operation = selectionController(state).issue(id, encoded, model)
const receipt = receipts.get(state)
if (receipt) {
void applyStructuredLaunchHeldOptions(state, receipt, false)
}
state.selection = { ...state.selection, held: { ...held, [id]: encoded } }
notifyStructuredLaunchListeners()
return new Promise((resolve) => replies.set(id, resolve))
return operation.result
}
async function setLaunchOption(
export async function setStructuredSessionSelectionOption(
target: RuntimeClientTarget,
sessionId: string,
fence: number,
key: string,
value: string
value: string,
options: { expectedEnvironmentPairingRevision?: number } = {}
): Promise<StructuredLaunchOptionOutcome> {
const fields = { key, value }
try {
const result = await callStructuredAgentSession<
AgentSessionMutationResult<AgentSessionOptionResult>
>(target, 'agentSession.setOption', {
envelope: {
sessionId,
clientOperationId: createStructuredAgentSessionOperationId(createBrowserUuid),
expectedRuntimeFence: fence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.setOption',
>(
target,
'agentSession.setOption',
{
envelope: {
sessionId,
fields
})
clientOperationId: createStructuredAgentSessionOperationId(createBrowserUuid),
expectedRuntimeFence: fence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.setOption',
sessionId,
fields
})
},
...fields
},
...fields
})
options
)
return result.ok
? { kind: 'accepted', options: result.value.options ?? { [key]: value } }
: { kind: 'refused', failure: agentSessionRefusalFailure(result.refusal) }
} catch (error) {
return {
kind: 'refused',
failure: agentSessionThrownFailure(
error,
error instanceof RuntimeRpcCallError ? error.code : undefined
)
}
const failure = agentSessionThrownFailure(
error,
error instanceof RuntimeRpcCallError ? error.code : undefined
)
return failure.kind === 'unconfirmed'
? { kind: 'unknown', failure }
: { kind: 'refused', failure }
}
}
function settleHeldOption(
state: StructuredLaunchState,
id: string,
encoded: string,
outcome: StructuredLaunchOptionOutcome
): void {
const {
held: { [id]: current, ...rest },
seed
} = state.selection
// A pick made while this one was in flight superseded it and stays held for the next pass.
const settled = current === encoded
state.selection = {
explicit: state.selection.explicit,
seed: outcome.kind === 'accepted' ? { ...seed, ...outcome.options } : seed,
held: settled ? rest : state.selection.held
}
if (settled) {
const replies = repliesFor(state)
replies.get(id)?.(outcome)
replies.delete(id)
}
notifyStructuredLaunchListeners()
state.callers.onSettled()
}
const prerequisites = new WeakMap<StructuredLaunchState, LegacyPrerequisites>()
/** Old hosts configure the known chat before submitting; Fast keeps saved-preference admission. */
/** One action decision per create attempt; picker settlement still belongs to its controller. */
export async function applyStructuredLaunchExplicitOptions(
state: StructuredLaunchState,
receipt: StructuredAgentLaunchReceipt
): Promise<StructuredAgentLaunchReceipt> {
const requested = structuredLaunchRequestedOptions({
explicit: state.selection.explicit,
held: state.selection.held
})
if (!requested) {
return applyStructuredLaunchHeldOptions(state, receipt)
}
const compatible = structuredLaunchCompatibleOptions(state.intent.agent, requested)
const entries = Object.entries(compatible).sort(([a], [b]) =>
a === 'model' ? -1 : b === 'model' ? 1 : 0
)
for (const [id, encoded] of entries) {
if (state.cancelled) {
throw new StructuredAgentSessionLaunchCancelledError()
}
if (id === 'fastMode' && state.selection.held[id] === undefined) {
continue
}
const outcome = await setLaunchOption(
state.intent.target,
receipt.sessionId,
receipt.fence,
id,
encoded
)
settleHeldOption(state, id, encoded, outcome)
if (outcome.kind === 'refused' && requested[id] !== undefined) {
for (const [key, value] of Object.entries(state.selection.held)) {
settleHeldOption(state, key, value, outcome)
let phase = prerequisites.get(state)
if (!phase || phase.intent !== state.intent) {
const controller = selectionController(state)
const intent = state.intent
phase = createLegacyPrerequisites(
state,
controller,
(key, value) =>
controller.request((options) =>
setStructuredSessionSelectionOption(
intent.target,
receipt.sessionId,
receipt.fence,
key,
value,
options
)
),
() => {
void applyStructuredLaunchHeldOptions(state, receipt, false)
}
return { ...receipt, configurationFailure: outcome.failure }
}
)
prerequisites.set(state, phase)
receipts.set(state, receipt)
phase.advance?.()
}
return applyStructuredLaunchHeldOptions(state, receipt)
const failure = await phase.decision
return failure ? { ...receipt, configurationFailure: failure } : receipt
}
const createHeld = new WeakMap<StructuredLaunchState, Readonly<Record<string, string>>>()
const createHeld = new WeakMap<StructuredLaunchState, readonly SelectionOperation[]>()
/** The held picks this create attempt carries, in whatever form it carries them. */
export function holdStructuredLaunchCreatePicks(
state: StructuredLaunchState,
held: Readonly<Record<string, string>>
): void {
createHeld.set(state, held)
/** Freeze identities, so a later equal-valued choice is a distinct operation. */
export function holdStructuredLaunchCreatePicks(state: StructuredLaunchState): void {
createHeld.set(state, selectionController(state).freeze(createHeld.get(state)))
}
// Durable-create picks already count as host intent; settle carried values while retaining newer choices.
export function settleStructuredLaunchCreateOptions(
state: StructuredLaunchState,
options: Readonly<Record<string, string>>
): void {
const carried = { ...options, ...createHeld.get(state) }
createHeld.delete(state)
for (const [id, encoded] of Object.entries(carried)) {
settleHeldOption(state, id, encoded, { kind: 'accepted', options })
for (const operation of createHeld.get(state) ?? []) {
selectionController(state).settle(operation, { kind: 'accepted', options })
}
createHeld.delete(state)
}
/** Apply held picks against the create fence until drained, model before effort. Report refusals to pickers without blocking launch publication. */
/** All desktop senders enroll launch prerequisites before the actual operation request. */
export function flushStructuredSessionSelectionOptions(
target: RuntimeClientTarget,
sessionId: string,
fence: number,
controller: ReturnType<typeof structuredSessionSelectionController>
): Promise<AgentSessionWriteFailure | undefined> | undefined {
const state = getStructuredLaunchStateBySessionId(sessionId)
const phase =
state && launchControllers.get(state) === controller ? prerequisites.get(state) : undefined
if (phase?.advance && phase.intent === state?.intent) {
phase.advance()
return phase.decision
}
controller.flush((operation) =>
controller.request((options) =>
setStructuredSessionSelectionOption(
target,
sessionId,
fence,
operation.key,
operation.value,
options
)
)
)
return undefined
}
/** Enqueue the current batch before attachment; later picks use this same ordered sender. */
export async function applyStructuredLaunchHeldOptions(
state: StructuredLaunchState,
receipt: StructuredAgentLaunchReceipt
receipt: StructuredAgentLaunchReceipt,
waitForDrain = true
): Promise<StructuredAgentLaunchReceipt> {
for (;;) {
if (state.cancelled) {
throw new StructuredAgentSessionLaunchCancelledError()
receipts.set(state, receipt)
const controller = selectionController(state)
const decision = flushStructuredSessionSelectionOptions(
state.intent.target,
receipt.sessionId,
receipt.fence,
controller
)
if (waitForDrain) {
if (decision) {
const failure = await decision
return failure ? { ...receipt, configurationFailure: failure } : receipt
}
const { held } = state.selection
const id = STRUCTURED_LAUNCH_OPTION_IDS.find((key) => held[key] !== undefined)
const encoded = id ? held[id] : undefined
if (!id || encoded === undefined) {
return receipt
}
const outcome = await setLaunchOption(
state.intent.target,
state.intent.sessionId,
receipt.fence,
id,
encoded
)
settleHeldOption(state, id, encoded, outcome)
await controller.drain()
}
return receipt
}
export function getStructuredAgentSessionLaunchSelection(
@@ -0,0 +1,402 @@
// @vitest-environment happy-dom
import {
mocks,
resetCreateMessageFixture,
cleanupCreateMessageFixture,
deferred,
createParams,
created,
target,
draft,
legacyAction,
acceptedOption,
refusedOption,
optionCalls
} from './structured-agent-session-launch-create-message.test-fixture'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
holdStructuredAgentSessionLaunchOption,
structuredLaunchSelectionController,
flushStructuredSessionSelectionOptions
} from './structured-agent-session-launch-options'
import {
structuredSessionSelectionController,
forgetStructuredSessionSelection
} from './structured-agent-session-selection-controller'
import {
getStructuredLaunchStateBySessionId,
resetStructuredAgentLaunchRegistryForTests
} from './agent-session/launch/structured-agent-session-launch-registry'
import {
resetStructuredAgentLaunchPersistenceForTests,
readStructuredAgentLaunchRecord
} from './agent-session/launch/structured-agent-session-launch-persistence'
import { getStructuredAgentSessionSendNotice } from '@/components/native-chat/structured-agent-session-pending-sends'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import { recoverPublishedSessionReceipt } from './agent-session/launch/structured-agent-session-launch-recovery'
import { publishStructuredLaunchCreateOptions } from './structured-agent-session-launch-create-message'
import {
retryStructuredAgentSessionLaunch,
getStructuredAgentSessionLaunchLifecycle
} from './structured-agent-session-launch'
let restoreCatalogStore: (() => void) | undefined
describe('legacy opening action prerequisites through real launch orchestration', () => {
beforeEach(() => {
restoreCatalogStore = resetCreateMessageFixture()
})
afterEach(() => {
cleanupCreateMessageFixture(restoreCatalogStore)
})
it.each([
['model', 'picked-model'],
['effort', 'low'],
['serviceTier', 'priority'],
['fastMode', 'true'],
['permissionMode', 'full-access']
])(
'returns required held %s failure through actual publication and prompt',
async (key, value) => {
const unrelated = deferred<unknown>()
const { launch, create } = legacyAction(
{ model: 'action-model', effort: 'high' },
async (field, encoded) =>
field === key
? refusedOption
: key === 'model' && field === 'permissionMode'
? unrelated.promise
: acceptedOption(field, encoded),
'local',
key === 'fastMode' ? 'claude' : 'codex'
)
const preference =
key === 'model'
? holdStructuredAgentSessionLaunchOption(launch.sessionId, 'permissionMode', 'ask')
: null
const held = holdStructuredAgentSessionLaunchOption(launch.sessionId, key, value)!
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await expect(held).resolves.toMatchObject({ kind: 'refused' })
await expect(launch.launchResult).resolves.toHaveProperty('configurationFailure')
await expect(launch.promptDeliveryResult).resolves.toMatchObject({
delivered: false,
inComposer: true
})
expect(draft(launch.sessionId)).toBe('opening text')
expect(getStructuredAgentSessionSendNotice(launch.sessionId)).toContain(
key === 'fastMode' ? 'Claude' : 'Codex'
)
expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.send')).toBe(false)
expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(false)
expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined()
expect(optionCalls().filter((call) => call.key === key)).toHaveLength(1)
if (key === 'effort') {
expect(optionCalls().map((call) => call.key)).toEqual(['model', 'effort'])
}
if (preference) {
expect(optionCalls().filter((call) => call.key === 'permissionMode')).toHaveLength(1)
unrelated.resolve(acceptedOption('permissionMode', 'ask'))
await expect(preference).resolves.toMatchObject({ kind: 'accepted' })
}
}
)
it.each([false, true])(
'keeps held unknown nonblocking only without explicit action (%s)',
async (explicit) => {
const { launch, create } = legacyAction(
explicit ? { model: 'action-model', effort: 'high' } : undefined,
async () => {
throw new Error('runtime_unavailable')
}
)
const held = holdStructuredAgentSessionLaunchOption(
launch.sessionId,
'model',
'picked-model'
)!
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await expect(held).resolves.toMatchObject({
kind: 'unknown',
failure: { kind: 'unconfirmed' }
})
const receipt = await launch.launchResult
expect(receipt.configurationFailure !== undefined).toBe(explicit)
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: !explicit })
expect(optionCalls()).toHaveLength(1)
}
)
it('rederives unsent dependents from retained exact handles and enrolls a later visited field', async () => {
const model = deferred<unknown>()
const effort = deferred<unknown>()
const writes: { key: string; value: string }[] = []
const { launch, create } = legacyAction(
{ model: 'B', effort: 'high', serviceTier: 'default' },
async (key, value) => {
writes.push({ key, value })
return writes.length === 1
? model.promise
: key === 'effort'
? effort.promise
: acceptedOption(key, value)
}
)
const obsolete = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B')!
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'C')
const newest = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B')!
await expect(obsolete).resolves.toEqual({ kind: 'superseded' })
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await vi.waitFor(() => expect(writes).toEqual([{ key: 'model', value: 'B' }]))
const owner = structuredLaunchSelectionController(
getStructuredLaunchStateBySessionId(launch.sessionId)!
)
const first = owner.current('model')!
expect(first.transmitted).toBe(true)
// A current query may retire the picker object before the action continuation.
const stopRead = owner.subscribe(() => {
const terminal = owner.captureRead()
if (terminal.length) {
stopRead()
owner.retire(terminal)
}
})
const later = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'C')!
await expect(later).resolves.toMatchObject({ kind: 'accepted' })
expect(owner.current('model')).toBeUndefined()
model.resolve(acceptedOption('model', 'B'))
await expect(newest).resolves.toMatchObject({ kind: 'accepted' })
await vi.waitFor(() => expect(writes).toHaveLength(3))
expect(writes[2]).toEqual({ key: 'effort', value: 'medium' })
effort.resolve(acceptedOption('effort', 'medium'))
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true })
expect(writes.map(({ key, value }) => [key, value])).toEqual([
['model', 'B'],
['model', 'C'],
['effort', 'medium']
])
})
it('preserves transmitted required failure after newer success and joins actual recovered reentry', async () => {
const older = deferred<unknown>()
const { launch, create } = legacyAction(
{ model: 'action-model', effort: 'high' },
async (key, value) => (value === 'B' ? older.promise : acceptedOption(key, value))
)
const b = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B')!
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await vi.waitFor(() => expect(optionCalls()).toHaveLength(1))
const state = getStructuredLaunchStateBySessionId(launch.sessionId)!
const c = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'C')!
await expect(c).resolves.toMatchObject({ kind: 'accepted' })
const original = state.intent
// Drive the real recovery→publication chain with its legitimate successor receipt.
const recovered = recoverPublishedSessionReceipt(state).then((receipt) =>
publishStructuredLaunchCreateOptions(state, receipt)
)
await vi.waitFor(() =>
expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.history')).toBe(
true
)
)
expect(state.intent).toBe(original)
older.resolve(refusedOption)
await expect(b).resolves.toMatchObject({ kind: 'refused' })
await expect(launch.launchResult).resolves.toHaveProperty('configurationFailure')
await expect(recovered).resolves.toMatchObject({ fence: 2, configurationFailure: {} })
await expect(
recoverPublishedSessionReceipt(state).then((receipt) =>
publishStructuredLaunchCreateOptions(state, receipt)
)
).resolves.toMatchObject({ fence: 2, configurationFailure: {} })
expect(optionCalls().map(({ value }) => value)).toEqual(['B', 'C'])
expect(draft(launch.sessionId)).toBe('opening text')
expect(
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')
).toHaveLength(0)
})
it('enrolls actual attached-owner requests before dispatch and hands unrelated picker work off on failure', async () => {
const model = deferred<unknown>()
const { launch, create } = legacyAction(
{ model: 'action-model', effort: 'high' },
(key, value) =>
key === 'model'
? model.promise
: key === 'permissionMode'
? Promise.resolve(refusedOption)
: Promise.resolve(acceptedOption(key, value))
)
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await vi.waitFor(() => expect(optionCalls()).toHaveLength(1))
const owner = structuredLaunchSelectionController(
getStructuredLaunchStateBySessionId(launch.sessionId)!
)
const pick = owner.issue('permissionMode', 'full-access', 'action-model')
void flushStructuredSessionSelectionOptions(target, launch.sessionId, 1, owner)
model.resolve(acceptedOption('model', 'action-model'))
await expect(pick.result).resolves.toMatchObject({ kind: 'refused' })
await expect(launch.launchResult).resolves.toHaveProperty('configurationFailure')
await expect(launch.promptDeliveryResult).resolves.toMatchObject({
delivered: false,
inComposer: true
})
expect(optionCalls().filter((call) => call.key === 'permissionMode')).toHaveLength(1)
expect(
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')
).toHaveLength(0)
})
it('returns the real keyless original-owner failure and one user Retry never rebinds option writes', async () => {
const paired = { kind: 'environment', environmentId: 'legacy-original' } as const
replaceRuntimeEnvironmentRevisions([{ id: paired.environmentId, createdAt: 1 }])
const raw = deferred<unknown>()
const { launch, create } = legacyAction(
{ model: 'action-model' },
() => raw.promise,
'runtime:legacy-original'
)
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await vi.waitFor(() => expect(optionCalls()).toHaveLength(1))
const state = getStructuredLaunchStateBySessionId(launch.sessionId)!
const original = state.intent
const owner = structuredLaunchSelectionController(state)
replaceRuntimeEnvironmentRevisions([{ id: paired.environmentId, createdAt: 2 }])
await expect(launch.launchResult).rejects.toThrow('owner retired')
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false })
expect(draft(launch.sessionId)).toBe('opening text')
expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe('failed')
raw.resolve(acceptedOption('model', 'action-model'))
expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true)
await expect(state.promise).rejects.toThrow('owner retired')
expect(state.intent).not.toBe(original)
expect(structuredLaunchSelectionController(state)).toBe(owner)
expect(optionCalls()).toHaveLength(1)
const targets = mocks.call.mock.calls.map(([host, method]) => ({ host, method }))
expect(targets).toEqual([
{ host: paired, method: 'agentSession.create' },
{ host: paired, method: 'agentSession.setOption' },
{ host: paired, method: 'agentSession.create' }
])
expect(mocks.admit).toHaveBeenCalledTimes(2)
expect(draft(launch.sessionId)).toBe('opening text')
forgetStructuredSessionSelection(launch.sessionId, paired)
replaceRuntimeEnvironmentRevisions([])
})
it('bounds real late enrollment work and releases its scoped observation at N and 10N', async () => {
const work: number[] = []
for (const count of [10, 100]) {
const waiting = deferred<unknown>()
const { launch, create } = legacyAction({ model: 'action-model' }, (key, value) =>
key === 'model' ? waiting.promise : Promise.resolve(acceptedOption(key, value))
)
const state = getStructuredLaunchStateBySessionId(launch.sessionId)!
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'action-model')
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high')
const owner = structuredLaunchSelectionController(state)
let subscriptions = 0
const subscribe = owner.subscribe
const observation = vi.spyOn(owner, 'subscribe').mockImplementation((listener) => {
subscriptions++
const stop = subscribe(listener)
return () => {
subscriptions--
stop()
}
})
let visits = 0
const issue = owner.issue
const access = vi.spyOn(owner, 'issue').mockImplementation((...args) => {
const operation = issue(...args)
for (const key of ['identity', 'key', 'outcome', 'transmitted'] as const) {
let value = operation[key]
Object.defineProperty(operation, key, {
configurable: true,
get() {
visits++
return value
},
set(next) {
value = next
}
})
}
return operation
})
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await vi.waitFor(() => expect(optionCalls()).toHaveLength(2))
for (let index = 0; index < count; index++) {
const before = visits
const pick = holdStructuredAgentSessionLaunchOption(
launch.sessionId,
'permissionMode',
index % 2 ? 'ask' : 'full-access'
)!
await expect(pick).resolves.toMatchObject({ kind: 'accepted' })
expect(visits - before).toBeLessThanOrEqual(80)
}
waiting.resolve(acceptedOption('model', 'action-model'))
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true })
expect(subscriptions).toBe(0)
expect(owner.pending()).toEqual([])
expect(optionCalls().filter((call) => call.key === 'permissionMode')).toHaveLength(count)
work.push(visits)
observation.mockRestore()
access.mockRestore()
resetStructuredAgentLaunchRegistryForTests()
resetStructuredAgentLaunchPersistenceForTests()
forgetStructuredSessionSelection(launch.sessionId)
mocks.call.mockClear()
}
console.info(
'legacy-phase-property-work',
JSON.stringify({ populations: [10, 100], visits: work })
)
expect(work[1]).toBeLessThanOrEqual(work[0] * 10)
})
it.each([false, true])(
'releases never-returning %s required requests at true end without reviving equal-text owners',
async (held) => {
const raw = deferred<unknown>()
const { launch, create } = legacyAction({ model: 'action-model' }, () => raw.promise)
if (held) {
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B')
}
await vi.waitFor(() => expect(createParams()).toBeDefined())
create.resolve(created())
await vi.waitFor(() => expect(optionCalls()).toHaveLength(1))
const owner = structuredLaunchSelectionController(
getStructuredLaunchStateBySessionId(launch.sessionId)!
)
const other = structuredSessionSelectionController(
{ kind: 'environment', environmentId: 'other-legacy' },
launch.sessionId,
'codex'
)
other.issue('model', 'other', null)
forgetStructuredSessionSelection(launch.sessionId, target)
await expect(launch.launchResult).rejects.toThrow('owner retired')
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false })
expect(draft(launch.sessionId)).toBe('opening text')
const replacement = structuredSessionSelectionController(target, launch.sessionId, 'codex')
expect(replacement).not.toBe(owner)
expect(other.overlay()).toEqual({ model: 'other' })
raw.resolve(acceptedOption('model', 'B'))
await Promise.resolve()
expect(replacement.overlay()).toEqual({})
expect(
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')
).toHaveLength(0)
forgetStructuredSessionSelection(launch.sessionId)
}
)
})
@@ -0,0 +1,137 @@
import type {
SelectionOperation,
SelectionOutcome
} from '../../../shared/structured-agent-session-selection-operations'
import type { AgentSessionWriteFailure } from '../../../shared/agent-session-write-failure'
import {
STRUCTURED_LAUNCH_OPTION_IDS,
structuredLaunchCompatibleOptions,
structuredLaunchRequestedOptions
} from '../../../shared/native-chat/input/native-chat-session-option-defaults'
import type { structuredSessionSelectionController } from './structured-agent-session-selection-controller'
import type { StructuredLaunchState } from './agent-session/launch/structured-agent-session-launch-registry'
import { StructuredAgentSessionLaunchCancelledError } from './agent-session/launch/structured-agent-session-launch-recovery'
export type LegacyPrerequisites = {
intent: StructuredLaunchState['intent']
decision: Promise<AgentSessionWriteFailure | undefined>
advance?: () => void
}
export function createLegacyPrerequisites(
state: StructuredLaunchState,
controller: ReturnType<typeof structuredSessionSelectionController>,
send: (key: string, value: string) => Promise<SelectionOutcome>,
handoff: () => void
): LegacyPrerequisites {
const result = Promise.withResolvers<AgentSessionWriteFailure | undefined>()
const active: LegacyPrerequisites = { intent: state.intent, decision: result.promise }
const explicit = state.selection.explicit
const context = new Map<string, SelectionOperation>()
const direct = new Set<string>()
const enrolled = new Set<SelectionOperation>()
let pending = 0
let release = () => {}
const finish = (failure?: AgentSessionWriteFailure, error?: unknown) => {
if (!active.advance) {
return
}
active.advance = undefined
release()
context.clear()
enrolled.clear()
if (error !== undefined) {
result.reject(error)
} else {
// Remaining picker work has its own result and must not delay failed action handback.
if (failure) {
handoff()
}
result.resolve(failure)
}
}
const perform = (key: string, value: string) => {
if (state.cancelled) {
throw new StructuredAgentSessionLaunchCancelledError()
}
return send(key, value)
}
const enroll = (
key: string,
value: string,
required: boolean,
operation?: SelectionOperation
) => {
pending++
if (operation) {
enrolled.add(operation)
}
const request = operation
? controller.transmit(operation, () => perform(key, value))
: (async () => perform(key, value))()
void request.then(
(outcome) => {
pending--
if (!active.advance) {
return
}
if (!controller.isCurrent()) {
finish(undefined, new Error('Conversation selection owner retired.'))
} else if (
required &&
(outcome.kind === 'refused' || outcome.kind === 'unknown') &&
outcome.failure
) {
finish(outcome.failure)
} else {
active.advance()
}
},
(error: unknown) => finish(undefined, error)
)
}
active.advance = () => {
if (!controller.isCurrent() || state.cancelled) {
finish(
undefined,
state.cancelled
? new StructuredAgentSessionLaunchCancelledError()
: new Error('Conversation selection owner retired.')
)
return
}
for (const key of STRUCTURED_LAUNCH_OPTION_IDS) {
const operation = controller.current(key)
if (operation) {
context.set(key, operation)
}
}
const held = Object.fromEntries([...context].map(([key, operation]) => [key, operation.value]))
const requested = structuredLaunchRequestedOptions({ explicit, held })
const compatible = requested && structuredLaunchCompatibleOptions(state.intent.agent, requested)
for (const key of STRUCTURED_LAUNCH_OPTION_IDS) {
const operation = context.get(key)
if (operation && !operation.outcome && !enrolled.has(operation)) {
enroll(key, operation.value, requested?.[key] !== undefined, operation)
}
const value = compatible?.[key]
if (value !== undefined && !context.has(key) && !direct.has(key) && key !== 'fastMode') {
if (!pending) {
direct.add(key)
enroll(key, value, requested?.[key] !== undefined)
}
return
}
}
if (pending) {
return
}
finish()
}
release = controller.subscribe(() => {
if (!controller.isCurrent()) {
active.advance?.()
}
})
return active
}
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import type * as LaunchErrors from '@/lib/structured-agent-session-launch-errors'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import type * as RecoveryModule from '@/lib/agent-session/launch/structured-agent-session-launch-recovery'
@@ -81,6 +81,7 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
runtimeEnvironmentCatalogHydrated: true,
unifiedTabsByWorktree: mocks.rendererTabs,
seedNativeChatLaunchDraft: mocks.seedDraft,
clearNativeChatLaunchDraft: mocks.clearDraft
@@ -127,6 +128,17 @@ import { resetStructuredAgentLaunchPersistenceForTests } from './agent-session/l
import { resetStructuredAgentLaunchRegistryForTests } from './agent-session/launch/structured-agent-session-launch-registry'
import { getStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options'
import { useAppStore } from '@/store'
import { registerStructuredSelectionCatalogStore } from './structured-agent-session-selection-controller'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
let restoreCatalog: () => void
afterEach(() => {
resetStructuredAgentLaunchRegistryForTests()
replaceRuntimeEnvironmentRevisions([])
restoreCatalog()
})
function launchIntent(
worktreeId: string,
sessionId = `session-${worktreeId}`
@@ -171,6 +183,12 @@ function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobile
}
}
function publishIntent(intent: StructuredAgentSessionLaunchIntent): void {
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(intent.worktreeId, intent.sessionId)
])
}
function sends(): unknown[][] {
return mocks.callRuntimeRpc.mock.calls.filter((call) => call[1] === 'agentSession.send')
}
@@ -187,7 +205,12 @@ async function flushLaunchSettlement(): Promise<void> {
describe('startStructuredAgentLaunch', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetAllMocks()
restoreCatalog = registerStructuredSelectionCatalogStore({
read: useAppStore.getState,
subscribe: useAppStore.subscribe
})
replaceRuntimeEnvironmentRevisions([{ id: 'server-1', createdAt: 1, pairingRevision: 1 }])
localStorage.clear()
resetStructuredAgentLaunchPersistenceForTests()
resetStructuredAgentLaunchRegistryForTests()
@@ -224,9 +247,7 @@ describe('startStructuredAgentLaunch', () => {
const intent = launchIntent(worktreeId, 'draft-session')
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-1',
@@ -251,9 +272,7 @@ describe('startStructuredAgentLaunch', () => {
const intent = launchIntent(worktreeId, 'long-draft-session')
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
const sixtyLineDraft = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join('\n')
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
@@ -341,9 +360,7 @@ describe('startStructuredAgentLaunch', () => {
const intent = launchIntent(worktreeId, 'session-1')
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
mocks.callStructuredAgentSession.mockResolvedValue({
ok: true,
value: { submission: { dispatchState: 'accepted' } }
@@ -467,9 +484,7 @@ describe('startStructuredAgentLaunch', () => {
() =>
new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve))
)
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
mocks.callStructuredAgentSession.mockResolvedValue({
ok: true,
value: { submission: { dispatchState: 'accepted' } }
@@ -494,9 +509,7 @@ describe('startStructuredAgentLaunch', () => {
() =>
new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve))
)
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
@@ -537,9 +550,7 @@ describe('startStructuredAgentLaunch', () => {
const pendingSends: ((result: unknown) => void)[] = []
mocks.createIntent.mockReturnValue(intent)
mocks.launch.mockImplementationOnce(() => new Promise((resolve) => (resolveLaunch = resolve)))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
mocks.callRuntimeRpc.mockImplementation(
() => new Promise((resolve) => pendingSends.push(resolve))
)
@@ -605,9 +616,7 @@ describe('startStructuredAgentLaunch', () => {
const intent = launchIntent(worktreeId)
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockRejectedValueOnce(new Error('response lost'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-20' })
await flushLaunchSettlement()
@@ -673,9 +682,7 @@ describe('startStructuredAgentLaunch', () => {
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-23', resumeFrom })
await flushLaunchSettlement()
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-24', resumeFrom })
await flushLaunchSettlement()
@@ -698,9 +705,7 @@ describe('startStructuredAgentLaunch', () => {
await expect(first.launchResult).rejects.toThrow('offline')
expect(first.releaseCallerAfterUnknownOutcome()).toBe(true)
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
// The chat's own Retry re-checks it; a new start would open a new chat instead.
expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
await flushLaunchSettlement()
@@ -749,9 +754,7 @@ describe('startStructuredAgentLaunch', () => {
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
.mockResolvedValueOnce({ sessionId: first.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, first.sessionId)
])
publishIntent(first)
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-27' })
await flushLaunchSettlement()
@@ -813,9 +816,7 @@ describe('startStructuredAgentLaunch', () => {
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
publishIntent(intent)
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-29',
@@ -49,7 +49,10 @@ import {
trackStructuredLaunchConfirmationDeadline
} from './agent-session/launch/structured-agent-session-launch-prompt-outcome'
import { repeatedStructuredLaunchAttempt } from './structured-agent-session-launch-request'
import { initialStructuredLaunchSelection } from './structured-agent-session-launch-options'
import {
initialStructuredLaunchSelection,
structuredLaunchSelectionController
} from './structured-agent-session-launch-options'
import { releaseStructuredLaunchSource } from './structured-agent-session-launch-source-handoff'
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
@@ -143,11 +146,9 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void {
resetStructuredLaunchCallers(state)
delete state.failure
state.callers.outcome = 'pending'
// New attempts use current local/paired create settings while preserving held picks through failure.
state.selection = {
...initialStructuredLaunchSelection(state.intent),
held: state.selection.held
}
// A new create seeds from the settings of now (a paired server's arrive with its probe); picks
// held through the failure still apply.
state.selection = initialStructuredLaunchSelection(state.intent)
state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions)
state.promise = publishWithHeldOptions(
state,
@@ -244,6 +245,7 @@ function structuredAgentLaunchState(
}
state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions)
callers.onSettled = () => maybeCleanupLaunchState(state)
structuredLaunchSelectionController(state)
setStructuredLaunchState(state)
reserveStructuredLaunchSlot(state.intent)
state.promise = publishWithHeldOptions(state, launchAndReconcile(state))
@@ -258,11 +260,7 @@ function structuredAgentLaunchState(
setStructuredLaunchState(state)
notifyStructuredLaunchListeners()
trackLaunchSettlement(state, state.promise)
return {
state,
caller,
joined: false
}
return { state, caller, joined: false }
}
export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: string): boolean {
@@ -0,0 +1,179 @@
import {
onRuntimeEnvironmentsRetired,
onRuntimeEnvironmentRevisionsChanged,
isRuntimeEnvironmentContinuationPending,
getRuntimeEnvironmentRevision,
resolveContinuedRuntimeEnvironmentRevision
} from '@/runtime/runtime-environment-revision'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import {
createStructuredSessionSelectionController,
type StructuredSessionSelectionController
} from '../../../shared/structured-agent-session-selection-operations'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { AgentType } from '../../../shared/agent-status-types'
import { enqueueSessionOptionSettingsWrite } from '@/components/native-chat/native-chat-session-option-settings-write'
type SelectionCatalogState = { runtimeEnvironmentCatalogHydrated: boolean }
type SelectionCatalogStore = {
read: () => SelectionCatalogState
subscribe: (listener: (state: SelectionCatalogState) => void) => () => void
}
let catalogStore: SelectionCatalogStore | undefined
// Cleanup imports from store slices must not construct the app store.
export function registerStructuredSelectionCatalogStore(store: SelectionCatalogStore): () => void {
const previous = catalogStore
catalogStore = store
return () => {
catalogStore = previous
}
}
export function structuredSessionSelectionAuthority(target: RuntimeClientTarget): string {
return target.kind === 'local'
? 'local'
: JSON.stringify([
getRuntimeEnvironmentRevision(target.environmentId),
getRuntimeEnvironmentConnectionGeneration(target.environmentId)
])
}
type SelectionRequestOptions = { expectedEnvironmentPairingRevision?: number }
type SessionSelectionController = StructuredSessionSelectionController & {
isCurrent(): boolean
request<T>(perform: (options: SelectionRequestOptions) => Promise<T>): Promise<T>
}
const controllers = new Map<
string,
{
sessionId: string
host: string
controller: SessionSelectionController
originalRevision: () => number | undefined
release: () => void
}
>()
/** Launch and attached views share the same sender on the execution host's logical chat. */
export function structuredSessionSelectionController(
target: RuntimeClientTarget,
sessionId: string,
agent: AgentType
) {
const host = target.kind === 'local' ? 'local' : target.environmentId
const key = JSON.stringify([host, sessionId, agent])
const existing = controllers.get(key)
if (existing) {
return existing.controller
}
const store = target.kind === 'environment' ? catalogStore : undefined
if (target.kind === 'environment' && !store) {
throw new Error('Selection catalog store must be registered before remote acquisition.')
}
let originalRevision =
target.kind === 'environment' ? getRuntimeEnvironmentRevision(target.environmentId) : undefined
const isCurrent = () => controllers.get(key)?.controller === controller
const controller: SessionSelectionController = {
...createStructuredSessionSelectionController((picks) => {
void controller
.request((options) => {
const mutation = { type: 'apply-picks', agent, picks } as const
return options.expectedEnvironmentPairingRevision === undefined
? enqueueSessionOptionSettingsWrite(target, mutation)
: enqueueSessionOptionSettingsWrite(target, mutation, options)
})
.catch(() => undefined)
}, isCurrent),
isCurrent,
async request(perform) {
if (!isCurrent()) {
throw new Error('Conversation selection owner retired.')
}
// An absent original pairing cannot confer authority over a later registration.
if (target.kind === 'environment' && originalRevision === undefined) {
throw new Error('Conversation host pairing unavailable.')
}
const result = await perform(
originalRevision === undefined
? {}
: {
expectedEnvironmentPairingRevision: resolveContinuedRuntimeEnvironmentRevision(
host,
originalRevision
)
}
)
if (!isCurrent()) {
throw new Error('Conversation selection owner retired.')
}
return result
}
}
let release = () => {}
controllers.set(key, {
sessionId,
host,
controller,
originalRevision: () => originalRevision,
release: () => release()
})
if (store && !store.read().runtimeEnvironmentCatalogHydrated) {
release = store.subscribe((state) => {
if (state.runtimeEnvironmentCatalogHydrated) {
originalRevision = getRuntimeEnvironmentRevision(host)
release()
}
})
}
return controller
}
function endSelection(key: string): void {
const entry = controllers.get(key)
if (entry) {
controllers.delete(key)
entry.release()
entry.controller.abandon()
}
}
/** Closing/purging a chat explicitly ends its transient operations, including ambiguous writes. */
export function forgetStructuredSessionSelection(
sessionId: string,
target?: RuntimeClientTarget
): void {
const host = target?.kind === 'local' ? 'local' : target?.environmentId
for (const [key, entry] of controllers) {
if (entry.sessionId === sessionId && (host === undefined || entry.host === host)) {
endSelection(key)
}
}
}
const retireHosts = (ids: readonly string[]) => {
for (const [key, entry] of controllers) {
if (ids.includes(entry.host)) {
endSelection(key)
}
}
}
onRuntimeEnvironmentsRetired(retireHosts)
onRuntimeEnvironmentRevisionsChanged((ids) => {
for (const [key, entry] of controllers) {
if (!ids.includes(entry.host)) {
continue
}
const original = entry.originalRevision()
const continued =
original === undefined
? undefined
: resolveContinuedRuntimeEnvironmentRevision(entry.host, original)
if (
continued !== getRuntimeEnvironmentRevision(entry.host) &&
!isRuntimeEnvironmentContinuationPending(entry.host, continued)
) {
endSelection(key)
}
}
})
@@ -0,0 +1,539 @@
import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots'
import type { StructuredAgentSessionMutate } from '@/components/native-chat/use-structured-agent-session-mutate'
import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire'
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { useAppStore } from '@/store'
import { useStructuredAgentSessionOptionState } from '@/components/native-chat/use-structured-agent-session-option-state'
import { structuredAgentSessionSeedCatalog } from '../../../shared/structured-agent-session-seed-catalog'
import { structuredAgentSessionMessageSelection } from '../../../shared/structured-agent-session-option-view'
import { structuredAgentSessionModelId } from '../../../shared/structured-agent-session-option-state'
import { startStructuredAgentLaunch } from './structured-agent-session-launch'
import {
holdStructuredAgentSessionLaunchOption,
structuredLaunchSelectionController
} from './structured-agent-session-launch-options'
import {
getStructuredLaunchStateBySessionId,
resetStructuredAgentLaunchRegistryForTests
} from './agent-session/launch/structured-agent-session-launch-registry'
import { useStructuredAgentSessionOptions } from '@/components/native-chat/use-structured-agent-session-options'
import { listRuntimeSessionHostsForStartup } from '@/app-shell/startup-runtime-session-hosts'
import { structuredAgentSessionTargetForTab } from '@/runtime/structured-agent-session-owner'
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
import {
registerStructuredSelectionCatalogStore,
forgetStructuredSessionSelection,
structuredSessionSelectionController
} from './structured-agent-session-selection-controller'
const unavailableMutation: StructuredAgentSessionMutate = async () => null
const transport = vi.hoisted(() => ({
create: vi.fn(),
intent: vi.fn(),
call: vi.fn(),
remember: vi.fn()
}))
vi.mock('./launch-structured-agent-session', () => ({
createStructuredAgentSessionLaunchIntent: transport.intent,
abandonStructuredAgentSessionLaunchIntent: vi.fn()
}))
vi.mock('./structured-agent-session-launch-create-call', () => ({
launchStructuredAgentSession: transport.create
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: transport.call
}))
vi.mock('@/components/native-chat/StructuredAgentSessionStatusBridge', () => ({
useStructuredAgentSessionStatusSummary: () => ({ summary: undefined, observation: 0 })
}))
vi.mock('@/runtime/structured-agent-session-host-capability', () => ({
useStructuredAgentSessionHostCapability: () => true,
useStructuredAgentSessionOwnPermissionState: () => 'supported',
structuredAgentSessionHostKey: () => 'paired-host'
}))
vi.mock('@/components/native-chat/native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: transport.remember
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() } }))
const target = { kind: 'environment', environmentId: 'paired-host' } as const
const sessionId = 'codex_reconnect'
const initial = useAppStore.getState()
const catalog = structuredAgentSessionSeedCatalog('codex')
const seed = { model: 'gpt-5.5' }
function publish(runtimeId: string, sequence: number) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the production status slice reads runtime identity and protocol fields here.
const status = {
runtimeId,
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 3
} as RuntimeStatus
useAppStore.getState().setRuntimeEnvironmentStatus(
target.environmentId,
{
status,
checkedAt: sequence,
snapshot: {
environmentId: target.environmentId,
pairingRevision: 1,
sequence,
checkedAt: sequence,
status,
verification: 'verified',
transport: 'ready'
}
},
{ suppressDisconnectToast: true }
)
}
afterEach(() => {
cleanup()
forgetStructuredSessionSelection(sessionId)
resetStructuredAgentLaunchRegistryForTests()
useAppStore.setState(initial, true)
vi.clearAllMocks()
})
it.each([false, true])(
'keeps launch, hook, drawn and captured operations through the real runtime transition (newer pick=%s)',
async (newer) => {
const intent: StructuredAgentSessionLaunchIntent = {
worktreeId: 'wt-lifetime',
executionHostId: 'runtime:paired-host',
target,
sessionId,
agent: 'codex',
seedOptions: seed,
params: {
agent: 'codex',
worktree: 'id:wt-lifetime',
envelope: {
sessionId,
clientOperationId: 'original-create',
payloadFingerprint: 'frozen',
expectedRuntimeFence: null
}
}
}
transport.intent.mockReturnValue(intent)
const create = Promise.withResolvers<{ sessionId: string; fence: number }>()
transport.create.mockReturnValue(create.promise)
const replies = [Promise.withResolvers<unknown>(), Promise.withResolvers<unknown>()]
const reads: ReturnType<typeof Promise.withResolvers<unknown>>[] = []
let sends = 0
transport.call.mockImplementation((_target, method) => {
if (method === 'agentSession.options') {
const read = Promise.withResolvers<unknown>()
reads.push(read)
return read.promise
}
if (method === 'agentSession.setOption') {
return replies[sends++].promise
}
return Promise.resolve({})
})
useAppStore.getState().setRuntimeEnvironments([environment(1)])
publish('runtime-A', 1)
const launch = startStructuredAgentLaunch('wt-lifetime', 'codex', {
requestId: 'lifetime',
executionHostId: 'runtime:paired-host'
})
const state = getStructuredLaunchStateBySessionId(sessionId)!
const owner = structuredLaunchSelectionController(state)
let original: ReturnType<typeof holdStructuredAgentSessionLaunchOption>
act(() => {
original = holdStructuredAgentSessionLaunchOption(sessionId, 'model', 'gpt-5.4')
})
const hook = renderHook(
({ visible }) =>
useStructuredAgentSessionOptions({
agent: 'codex',
sessionId,
target,
fence: visible ? 7 : null,
launch: { kind: 'new', seedOptions: seed },
isVisible: false,
transportEnabled: true,
providerVisible: visible,
turnId: null,
unloadedTurnRevisions: undefined,
mutate: unavailableMutation
}),
{ initialProps: { visible: false } }
)
const readHook = renderHook(() =>
useStructuredAgentSessionOptionState({
agent: 'codex',
optionCatalog: catalog,
identity: `codex:${sessionId}`,
fence: 1,
sessionId,
target,
newLaunch: true,
seedsModel: true,
providerVisible: true,
providerStarting: false,
hostOptionStamp: '',
readsBeforeStart: true,
turnId: null,
unloadedTurnRevisions: undefined
})
)
expect(reads).toHaveLength(1)
const capture = () => {
const source = hook.result.current.sendSource
const view = source.optionView(source.optionStateRef.current, seed)
return {
drawn: structuredAgentSessionModelId(view.record, 'own'),
selection: structuredAgentSessionMessageSelection(view)
}
}
let settled = false
void original!.then(() => {
settled = true
})
const retainedOwners = new Set([owner])
for (let generation = 2; generation <= (newer ? 100 : 10); generation++) {
act(() => publish(`runtime-${generation}`, generation))
retainedOwners.add(structuredSessionSelectionController(target, sessionId, 'codex'))
retainedOwners.add(structuredLaunchSelectionController(state))
}
expect(retainedOwners.size).toBe(1)
await act(async () => reads[0].resolve({ models: [], current: { model: 'stale-generation' } }))
expect(readHook.result.current.optionState.optionsAnswerPresent).not.toBe(true)
readHook.unmount()
reads.length = 0
expect(structuredSessionSelectionController(target, sessionId, 'codex')).toBe(owner)
expect(structuredLaunchSelectionController(state)).toBe(owner)
expect(settled).toBe(false)
expect(capture()).toMatchObject({ drawn: 'gpt-5.4', selection: { model: 'gpt-5.4' } })
let chosen = original!
if (newer) {
act(() => {
chosen = holdStructuredAgentSessionLaunchOption(sessionId, 'model', 'gpt-5.3-codex')!
})
await expect(original!).resolves.toEqual({ kind: 'superseded' })
}
const model = newer ? 'gpt-5.3-codex' : 'gpt-5.4'
expect(capture()).toMatchObject({ drawn: model, selection: { model } })
await act(async () => create.resolve({ sessionId, fence: 7 }))
expect(sends).toBe(1)
hook.rerender({ visible: true })
await waitFor(() => expect(reads).toHaveLength(1))
let attached: Promise<boolean> | undefined
if (newer) {
act(() => {
attached = hook.result.current.setStructuredOption('effort', 'high')
})
expect(sends).toBe(2)
expect(capture().selection).toMatchObject({ model, effort: 'high' })
await act(async () =>
replies[1].resolve({ ok: true, value: { options: { model, effort: 'high' } } })
)
await expect(attached).resolves.toBe(true)
expect(transport.remember.mock.calls[0][1]).toMatchObject({
picks: [{ modelId: model, optionId: 'effort', value: 'high' }]
})
}
// This query predates acceptance and cannot acknowledge its later terminal identity.
await act(async () => replies[0].resolve({ ok: true, value: { options: { model } } }))
await expect(chosen).resolves.toMatchObject({ kind: 'accepted' })
await launch.launchResult
const completed = owner.captureRead()
expect(completed).toHaveLength(newer ? 2 : 1)
expect(transport.remember).toHaveBeenCalledTimes(newer ? 2 : 1)
if (newer) {
expect(transport.remember.mock.calls[1][1]).toMatchObject({
picks: [
{ modelId: model, optionId: 'model', value: model, adoptModelAsLaunchDefault: false }
]
})
}
expect(hook.result.current.sendSource.optionStateRef.current.optionsAnswerPresent).not.toBe(
true
)
await act(async () => reads[0].resolve({ models: [], current: { model: 'other-client' } }))
await waitFor(() => expect(reads).toHaveLength(2))
expect(owner.captureRead()).toEqual(completed)
await act(async () => reads[1].resolve({ models: [], current: { model: 'other-client' } }))
await waitFor(() => expect(owner.captureRead()).toEqual([]))
expect(owner.overlay()).toEqual({})
expect(capture().drawn).toBe('other-client')
}
)
const environment = (revision: number): PublicKnownRuntimeEnvironment => ({
id: target.environmentId,
name: 'restored',
createdAt: 1,
updatedAt: revision,
pairingRevision: revision,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'loopback'
})
it.each(['present', 'initially-absent', 'already-hydrated'] as const)(
'initializes restored hook affinity only at the first authoritative publication (%s)',
async (mode) => {
useAppStore.getState().setRuntimeEnvironments([])
useAppStore.setState({
runtimeEnvironmentCatalogHydrated: mode === 'already-hydrated',
runtimeEnvironmentCatalogSettled: false
})
const list = vi
.fn()
.mockRejectedValueOnce(new Error('first list failed'))
.mockResolvedValue([environment(1)])
const previousApi = window.api
Object.defineProperty(window, 'api', {
value: { runtimeEnvironments: { list } },
configurable: true,
writable: true
})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
if (mode !== 'already-hydrated') {
await useAppStore.getState().hydrateRuntimeEnvironmentStatuses()
expect(useAppStore.getState().runtimeEnvironmentCatalogHydrated).toBe(false)
expect(useAppStore.getState().runtimeEnvironmentCatalogSettled).toBe(true)
expect((await listRuntimeSessionHostsForStartup()).hostIds).toEqual(['runtime:paired-host'])
}
const restored = structuredAgentSessionTargetForTab(useAppStore.getState(), {
worktreeId: 'restored',
executionHostId: 'runtime:paired-host'
})!
transport.call.mockResolvedValue({ models: [], current: { model: 'gpt-5.5' } })
const hook = renderHook(
({ visible }) =>
useStructuredAgentSessionOptionState({
agent: 'codex',
optionCatalog: catalog,
identity: `codex:${sessionId}`,
fence: 7,
sessionId,
target: restored,
newLaunch: false,
seedsModel: false,
providerVisible: visible,
providerStarting: false,
hostOptionStamp: '',
readsBeforeStart: false,
turnId: null,
unloadedTurnRevisions: undefined
}),
{ initialProps: { visible: false } }
)
const owner = hook.result.current.controller
const held = owner.issue('model', 'gpt-5.4', null)
const unqualified = vi.fn()
await expect(owner.request(unqualified)).rejects.toThrow('unavailable')
expect(unqualified).not.toHaveBeenCalled()
act(() =>
useAppStore.getState().setRuntimeEnvironments(mode === 'present' ? [environment(1)] : [])
)
expect(hook.result.current.controller).toBe(owner)
if (mode === 'present') {
hook.rerender({ visible: true })
await waitFor(() =>
expect(transport.call).toHaveBeenCalledWith(
restored,
'agentSession.options',
expect.anything(),
{ expectedEnvironmentPairingRevision: 1 }
)
)
expect(held.outcome).toBeUndefined()
act(() => useAppStore.getState().setRuntimeEnvironments([environment(2)]))
expect(held.outcome).toEqual({ kind: 'superseded' })
await expect(owner.request(async () => 'unsafe')).rejects.toThrow('retired')
} else {
act(() => useAppStore.getState().setRuntimeEnvironments([environment(2)]))
const send = vi.fn()
await expect(owner.request(send)).rejects.toThrow('unavailable')
expect(send).not.toHaveBeenCalled()
}
} finally {
error.mockRestore()
Object.defineProperty(window, 'api', {
value: previousApi,
configurable: true,
writable: true
})
}
}
)
it.each([false, true])(
'retains the answered desktop descriptor and capture across contact, not true end (pending=%s)',
async (pending) => {
resetHostModelCatalogSnapshotsForTests()
useAppStore.getState().setRuntimeEnvironments([environment(1)])
publish('answered-runtime-1', 1)
const answer: AgentSessionOptionsResult = {
models: ['gpt-5.5', 'gpt-5.4'].map((id) => ({
id,
label: id,
isDefault: false,
efforts: []
})),
current: {
model: 'gpt-5.5',
permissionMode: 'own',
reportedPermissionMode: 'full-access',
acceptedInputSources: { model: 'saved' }
}
}
const reads: ReturnType<typeof Promise.withResolvers<AgentSessionOptionsResult>>[] = []
const write = Promise.withResolvers<unknown>()
transport.call.mockImplementation((_target, method) => {
if (method === 'agentSession.setOption') {
return write.promise
}
if (method === 'agentSession.modelCatalog') {
return Promise.resolve({ origin: 'probe', models: answer.models, fetchedAt: 1 })
}
if (method !== 'agentSession.options') {
return new Promise(() => {})
}
const read = Promise.withResolvers<AgentSessionOptionsResult>()
reads.push(read)
return read.promise
})
const hook = renderHook(
({ stamp, fence }) =>
useStructuredAgentSessionOptions({
agent: 'codex',
sessionId,
target,
fence,
transportEnabled: true,
isVisible: true,
providerVisible: true,
turnId: null,
unloadedTurnRevisions: undefined,
hostOptionStamp: stamp,
mutate: unavailableMutation
}),
{ initialProps: { stamp: 'initial', fence: 1 } }
)
const owner = structuredSessionSelectionController(target, sessionId, 'codex')
await waitFor(() => expect(reads).toHaveLength(1))
await act(async () => reads[0].resolve(answer))
const descriptor = (id: string) =>
hook.result.current.optionSnapshot.find((row) => row.id === id)
const capture = () => {
const source = hook.result.current.sendSource
return structuredAgentSessionMessageSelection(
source.optionView(source.optionStateRef.current, undefined)
)
}
expect(descriptor('model')?.kind).toMatchObject({ currentValue: 'gpt-5.5' })
expect(descriptor('permissionMode')).toMatchObject({
description: 'Full access',
settable: true
})
let picked: Promise<boolean> | undefined
if (pending) {
act(() => {
picked = hook.result.current.setStructuredOption('model', 'gpt-5.4')
})
}
hook.rerender({ stamp: 'old-read', fence: 1 })
await waitFor(() => expect(reads).toHaveLength(2))
act(() => publish('answered-runtime-2', 2))
await waitFor(() => expect(reads.length).toBeGreaterThanOrEqual(3))
expect(structuredSessionSelectionController(target, sessionId, 'codex')).toBe(owner)
const model = pending ? 'gpt-5.4' : 'gpt-5.5'
expect(descriptor('model')?.kind).toMatchObject({ currentValue: model })
expect(capture()).toEqual({ model })
expect(hook.result.current.sendSource.optionStateRef.current.acceptedInputSources).toEqual({
model: 'saved'
})
expect(descriptor('permissionMode')).toMatchObject({
description: 'Permissions',
settable: false
})
await act(async () =>
reads[1].resolve({
...answer,
conversationCommands: ['compact'],
current: { model: 'stale' }
})
)
expect(hook.result.current.conversationCommands).toEqual([])
if (pending) {
await act(async () => write.resolve({ ok: true, value: { options: { model } } }))
await expect(picked).resolves.toBe(true)
expect(owner.captureRead()).toHaveLength(1)
}
await act(async () =>
reads[2].resolve({ ...answer, current: { model: 'gpt-5.5', acceptedInputSources: {} } })
)
if (pending) {
await waitFor(() => expect(reads).toHaveLength(4))
expect(owner.captureRead()).toHaveLength(1)
}
if (reads[3]) {
await act(async () =>
reads[3].resolve({ ...answer, current: { model: 'gpt-5.5', acceptedInputSources: {} } })
)
}
await waitFor(() =>
expect(hook.result.current.sendSource.optionStateRef.current.acceptedInputSources).toEqual({})
)
expect(owner.captureRead()).toEqual([])
expect(descriptor('model')?.kind).toMatchObject({ currentValue: 'gpt-5.5' })
expect(capture()).toEqual({})
act(() => forgetStructuredSessionSelection(sessionId, target))
hook.rerender({ stamp: 'new-owner', fence: 2 })
expect(structuredSessionSelectionController(target, sessionId, 'codex')).not.toBe(owner)
await waitFor(() =>
expect(hook.result.current.sendSource.optionStateRef.current.optionsAnswerPresent).not.toBe(
true
)
)
expect(capture()).toEqual({})
await expect(owner.request(async () => 'unsafe')).rejects.toThrow('retired')
}
)
it('releases the actual first-publication observer when its owner ends before publication', async () => {
useAppStore.getState().setRuntimeEnvironments([])
useAppStore.setState({ runtimeEnvironmentCatalogHydrated: false })
const released = vi.fn()
const restore = registerStructuredSelectionCatalogStore({
read: useAppStore.getState,
subscribe(listener) {
const stop = useAppStore.subscribe(listener)
return () => {
released()
stop()
}
}
})
try {
const owner = structuredSessionSelectionController(target, sessionId, 'codex')
const pick = owner.issue('model', 'gpt-5.4', null)
forgetStructuredSessionSelection(sessionId, target)
await expect(pick.result).resolves.toEqual({ kind: 'superseded' })
expect(released).toHaveBeenCalledTimes(1)
useAppStore.getState().setRuntimeEnvironments([environment(1)])
expect(released).toHaveBeenCalledTimes(1)
await expect(owner.request(async () => 'unsafe')).rejects.toThrow('retired')
const replacement = structuredSessionSelectionController(target, sessionId, 'codex')
expect(replacement).not.toBe(owner)
await expect(replacement.request(async (options) => options)).resolves.toEqual({
expectedEnvironmentPairingRevision: 1
})
} finally {
forgetStructuredSessionSelection(sessionId, target)
restore()
}
})
@@ -85,6 +85,14 @@ export function getRuntimeEnvironmentRevision(environmentId: string): number | u
return revisionByEnvironmentId.get(environmentId)
}
/** Selected catalog projection also changes when a pending host key is proved at the same revision. */
export function runtimeEnvironmentContinuityStamp(environmentId: string): string {
return JSON.stringify([
revisionByEnvironmentId.get(environmentId),
pendingContinuationByEnvironmentId.get(environmentId)
])
}
/**
* The newest pairing proven to be the same machine as `revision`. A holder of an older pairing may
* follow a same-host rotation, but never a re-pair whose host identity is different or unresolved.
@@ -20,6 +20,12 @@ vi.mock('@/store', async () => {
import { useAppStore } from '@/store'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained'
import {
structuredSessionSelectionController,
forgetStructuredSessionSelection,
registerStructuredSelectionCatalogStore
} from '@/lib/structured-agent-session-selection-controller'
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
function makeStatus(runtimeId: string): RuntimeStatus {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture carries only the fields the slice reads.
@@ -35,6 +41,26 @@ function makeStatus(runtimeId: string): RuntimeStatus {
} as RuntimeStatus
}
const env = (revision: number, key?: string): PublicKnownRuntimeEnvironment => ({
id: 'env-a',
name: 'managed',
createdAt: 1,
updatedAt: revision,
pairingRevision: revision,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'loopback',
connectionDependency: 'ssh-tunnel',
hostKeyFingerprint: key,
orcadDeployment: {
sshTargetId: 'box',
sshTargetGeneration: 1,
localPort: 46768,
remotePort: 6768
}
})
let lastVerified: RuntimeStatus | null = null
// Same-runtime return retains verified status and advances contact epoch, not connection generation.
@@ -55,7 +81,12 @@ function publish(status: RuntimeStatus | null, checkedAt: number): void {
})
}
let restoreCatalog: () => void
beforeEach(() => {
restoreCatalog = registerStructuredSelectionCatalogStore({
read: useAppStore.getState,
subscribe: useAppStore.subscribe
})
clearRuntimeEnvironmentConnectionGenerationsForTests()
vi.stubGlobal('window', { api: {}, dispatchEvent: vi.fn() })
useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })
@@ -63,6 +94,9 @@ beforeEach(() => {
})
afterEach(() => {
forgetStructuredSessionSelection('continuity')
forgetStructuredSessionSelection('keyless')
restoreCatalog()
vi.unstubAllGlobals()
})
@@ -107,4 +141,50 @@ describe('subscribeRuntimeHostContactRegained', () => {
expect(listener).not.toHaveBeenCalled()
stop()
})
it('wakes a parked owner when a pending managed pairing proves its key without another revision', async () => {
useAppStore.getState().setRuntimeEnvironments([env(1, 'key-A')])
const target = { kind: 'environment', environmentId: 'env-a' } as const
const owner = structuredSessionSelectionController(target, 'continuity', 'codex')
const held = owner.issue('model', 'gpt-5.4', null)
const listener = vi.fn()
const stop = subscribeRuntimeHostContactRegained('env-a', listener)
useAppStore.getState().setRuntimeEnvironments([env(2)])
expect(structuredSessionSelectionController(target, 'continuity', 'codex')).toBe(owner)
expect(held.outcome).toBeUndefined()
const pendingWakes = listener.mock.calls.length
useAppStore.getState().setRuntimeEnvironments([env(2, 'key-A')])
expect(listener).toHaveBeenCalledTimes(pendingWakes + 1)
useAppStore.getState().setRuntimeEnvironments([env(2, 'key-A')])
expect(listener).toHaveBeenCalledTimes(pendingWakes + 1)
useAppStore.getState().setRuntimeEnvironments([env(3)])
expect(structuredSessionSelectionController(target, 'continuity', 'codex')).toBe(owner)
expect(held.outcome).toBeUndefined()
useAppStore.getState().setRuntimeEnvironments([env(3, 'key-A')])
const send = vi.fn().mockResolvedValue('current')
await owner.request(send)
expect(send).toHaveBeenCalledWith({ expectedEnvironmentPairingRevision: 3 })
useAppStore.getState().setRuntimeEnvironments([env(4, 'different')])
expect(held.outcome).toEqual({ kind: 'superseded' })
expect(structuredSessionSelectionController(target, 'continuity', 'codex')).not.toBe(owner)
stop()
forgetStructuredSessionSelection('continuity')
})
})
it('ends supported keyless SSH affinity when the classifier supplies neither proof nor pending', async () => {
useAppStore.getState().setRuntimeEnvironments([env(1)])
const target = { kind: 'environment', environmentId: 'env-a' } as const
const owner = structuredSessionSelectionController(target, 'keyless', 'codex')
const held = owner.issue('model', 'gpt-5.4', null)
useAppStore.getState().setRuntimeEnvironments([env(2)])
await expect(held.result).resolves.toEqual({ kind: 'superseded' })
const send = vi.fn().mockResolvedValue('current')
await expect(owner.request(send)).rejects.toThrow('retired')
expect(send).not.toHaveBeenCalled()
const fresh = structuredSessionSelectionController(target, 'keyless', 'codex')
expect(fresh).not.toBe(owner)
await expect(fresh.request(send)).resolves.toBe('current')
expect(send).toHaveBeenCalledWith({ expectedEnvironmentPairingRevision: 2 })
forgetStructuredSessionSelection('keyless')
})
@@ -1,15 +1,22 @@
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { runtimeEnvironmentContinuityStamp } from './runtime-environment-revision'
function contactMarks(state: AppState, environmentId: string): string | null {
function contactMarks(state: AppState, environmentId: string): string {
const entry = state.runtimeStatusByEnvironmentId.get(environmentId)
return entry ? `${entry.hostContactEpoch ?? 0}:${entry.connectionGeneration ?? 0}` : null
return entry
? JSON.stringify([
entry.hostContactEpoch ?? 0,
entry.connectionGeneration ?? 0,
runtimeEnvironmentContinuityStamp(environmentId)
])
: runtimeEnvironmentContinuityStamp(environmentId)
}
/**
* Calls `listener` each time this client regains contact with an environment's runtime: the
* same-runtime return (`hostContactEpoch`) or a new runtime session (`connectionGeneration`),
* both published by runtime-status.ts.
* or resolved pairing continuity published by runtime-status.ts.
*/
export function subscribeRuntimeHostContactRegained(
environmentId: string,
@@ -17,12 +24,14 @@ export function subscribeRuntimeHostContactRegained(
): () => void {
let lastMarks = contactMarks(useAppStore.getState(), environmentId)
return useAppStore.subscribe((state, previousState) => {
if (state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId) {
if (
state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId &&
state.runtimeEnvironments === previousState.runtimeEnvironments
) {
return
}
const marks = contactMarks(state, environmentId)
// Why keep the last marks across a missing entry: a cleared status that returns is a reconnect.
if (marks === null || marks === lastMarks) {
if (marks === lastMarks) {
return
}
lastMarks = marks
@@ -0,0 +1,100 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createStructuredSessionSelectionController } from '../../../shared/structured-agent-session-selection-operations'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../shared/protocol-version'
import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
const runtimeCall = vi.fn()
const runtimeEnvironmentCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
runtimeCall.mockReset()
runtimeEnvironmentCall.mockReset()
vi.stubGlobal('window', {
api: { runtime: { call: runtimeCall }, runtimeEnvironments: { call: runtimeEnvironmentCall } }
})
})
it.each(['local', 'environment'] as const)(
'issues Plan and legacy tier before Send while the model response waits (%s)',
async (kind) => {
const delayedModel = Promise.withResolvers<unknown>()
const host = { permissionMode: 'full-access', serviceTier: 'default', model: 'opus' }
const enqueued: string[] = []
const receive = async ({
method,
params
}: {
method: string
params?: { key?: string; value?: string }
}) => {
if (method === 'status.get') {
return {
id: method,
ok: true,
result: {
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
},
_meta: { runtimeId: 'remote-runtime' }
}
}
enqueued.push(params?.key ?? method)
if (method === 'agentSession.setOption') {
if (params?.key === 'model') {
host.model = params.value!
return delayedModel.promise
}
if (params?.key === 'permissionMode') {
host.permissionMode = params.value!
}
if (params?.key === 'serviceTier') {
host.serviceTier = params.value!
}
}
return { id: method, ok: true, result: { ...host }, _meta: { runtimeId: 'remote-runtime' } }
}
runtimeCall.mockImplementation(receive)
runtimeEnvironmentCall.mockImplementation(receive)
const target =
kind === 'local'
? { kind: 'local' as const }
: { kind: 'environment' as const, environmentId: 'w2-peer' }
const controller = createStructuredSessionSelectionController(() => {})
const dispatch = () =>
controller.flush(async (operation) => {
await callRuntimeRpc(target, 'agentSession.setOption', {
key: operation.key,
value: operation.value
})
return { kind: 'accepted' as const, options: { [operation.key]: operation.value } }
})
controller.issue('model', 'sonnet', null)
dispatch()
const draining = controller.drain()
await vi.waitFor(() => expect(enqueued).toEqual(['model']))
controller.issue('permissionMode', 'plan', null)
controller.issue('serviceTier', 'priority', 'sonnet')
void dispatch()
const delivered = await callRuntimeRpc(target, 'agentSession.send', {})
expect(enqueued).toEqual(['model', 'serviceTier', 'permissionMode', 'agentSession.send'])
expect(delivered).toMatchObject({
model: 'sonnet',
permissionMode: 'plan',
serviceTier: 'priority'
})
expect(controller.overlay()).toMatchObject({ permissionMode: 'plan', serviceTier: 'priority' })
delayedModel.resolve({
id: 'model',
ok: true,
result: {},
_meta: { runtimeId: 'remote-runtime' }
})
await draining
controller.retire(controller.captureRead())
expect(controller.overlay()).toEqual({})
expect(host).toEqual(delivered)
}
)
@@ -37,7 +37,7 @@ import { subscribeRuntimeRpc } from './runtime-rpc-subscribe'
/** Read a capability through the runtime's existing status cache. A failed/unknown
* probe is treated as legacy so a newer call is never made before the host has
* proved it understands it. */
async function structuredAgentSessionHostSupports(
export async function structuredAgentSessionHostSupports(
target: RuntimeClientTarget,
capability: RuntimeCapability
): Promise<boolean> {
@@ -166,7 +166,11 @@ export async function callStructuredAgentSession<TResult>(
method: string,
params?: unknown,
/** For a caller that checked the remote host's compatibility itself, just before. */
options: { skipCompatibilityCheck?: true; beforeSend?: () => void } = {}
options: {
skipCompatibilityCheck?: true
beforeSend?: () => void
expectedEnvironmentPairingRevision?: number
} = {}
): Promise<TResult> {
if (
method === 'agentSession.rewind' &&
@@ -179,7 +183,7 @@ export async function callStructuredAgentSession<TResult>(
throw new Error('Rewinding requires a newer Orca server. Update the server and try again.')
}
const timeoutMs = STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS.get(method)
return timeoutMs === undefined && !options.skipCompatibilityCheck && !options.beforeSend
return timeoutMs === undefined && Object.keys(options).length === 0
? callRuntimeRpc<TResult>(target, method, params)
: callRuntimeRpc<TResult>(target, method, params, {
...(timeoutMs === undefined ? {} : { timeoutMs }),
@@ -7,10 +7,18 @@ vi.mock('../components/native-chat/StructuredAgentSessionStatusBridge', () => ({
useStructuredAgentSessionStatusSummary: () => ({ summary: null, observation: 0 })
}))
// Existing non-Own action gates have their own unchanged request/recovery contracts.
vi.mock('./structured-agent-session-host-capability', async (original) => ({
...(await original<typeof HostCapability>()),
useStructuredAgentSessionHostCapability: () => false
}))
vi.mock('./structured-agent-session-host-capability', async (original) => {
const actual = await original<typeof HostCapability>()
return {
...actual,
useStructuredAgentSessionHostCapability: (
...[target, capability]: Parameters<typeof actual.useStructuredAgentSessionHostCapability>
) =>
capability === 'agent-session.permission-modes.v1'
? actual.useStructuredAgentSessionHostCapability(target, capability)
: false
}
})
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('./structured-agent-session-client', () => ({
callStructuredAgentSession: async () => ({
@@ -36,6 +44,7 @@ const owners: RuntimeHostStatusOwner[] = []
afterEach(() => {
owners.splice(0).forEach((owner) => owner.dispose())
cleanup()
useAppStore.getState().setRuntimeEnvironments([])
clearRuntimeCompatibilityCacheForTests()
vi.unstubAllGlobals()
vi.restoreAllMocks()
@@ -44,22 +53,20 @@ afterEach(() => {
it.each([10, 100])(
'enables %s mounted consumers from the existing connection owner',
async (count) => {
useAppStore.setState({
runtimeEnvironments: [
{
id: 'host',
name: 'host',
createdAt: 1,
updatedAt: 1,
pairingRevision: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'host'
}
],
runtimeStatusByEnvironmentId: new Map()
})
useAppStore.getState().setRuntimeEnvironments([
{
id: 'host',
name: 'host',
createdAt: 1,
updatedAt: 1,
pairingRevision: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'host'
}
])
useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })
let response = Promise.withResolvers<ReturnType<typeof createCompatibleRuntimeStatusResponse>>()
const request = vi.fn(() => response.promise)
const owner = new RuntimeHostStatusOwner({
@@ -5,6 +5,7 @@ import type { StructuredLaunchSlotOwner } from '@/lib/agent-session/launch/struc
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
const mocks = vi.hoisted(() => ({
remember: vi.fn(),
closeSession:
vi.fn<(target: RuntimeClientTarget, sessionId: string) => Promise<'closed' | 'unsupported'>>(),
callRuntime:
@@ -29,6 +30,9 @@ vi.mock('@/lib/agent-session/launch/structured-agent-session-launch-registry', (
hasStructuredAgentSessionLaunchCancellationTombstone: mocks.hasTombstone,
markStructuredAgentSessionLaunchCancelled: mocks.markCancelled
}))
vi.mock('@/components/native-chat/native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: mocks.remember
}))
vi.mock('@/lib/structured-agent-session-launch-prompt', () => ({
discardStructuredAgentSessionChatSends: mocks.discardOutbox
}))
@@ -74,6 +78,14 @@ import {
import { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner'
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
import {
structuredSessionSelectionController,
forgetStructuredSessionSelection
} from '@/lib/structured-agent-session-selection-controller'
import { retirePurgedTabLaunches } from '@/store/slices/worktrees/teardown/purged-structured-launch-retirement'
import { useAppStore } from '@/store'
import { agentSessionTabFixture } from '@/components/native-chat/native-chat-workspace-test-fixtures'
const layoutOwner: StructuredLaunchSlotOwner = {
unifiedTabsByWorktree: {},
groupsByWorktree: {},
@@ -376,3 +388,100 @@ describe('structured agent session tab retirement', () => {
warn.mockRestore()
})
})
it('releases ordinary close/purge owners while retaining the same-session other-host scope', async () => {
const remote = { kind: 'environment', environmentId: 'selection-generations' } as const
const local = { kind: 'local' } as const
const sessionId = 'selection-lifetime'
const retained = new Set<string>()
const set = Map.prototype.set
const del = Map.prototype.delete
const added = vi.spyOn(Map.prototype, 'set').mockImplementation(function (
this: Map<unknown, unknown>,
key,
value
) {
if (value?.sessionId === sessionId && value?.controller) {
retained.add(key)
}
return set.call(this, key, value)
})
const deleted = vi.spyOn(Map.prototype, 'delete').mockImplementation(function (
this: Map<unknown, unknown>,
key
) {
retained.delete(key)
return del.call(this, key)
})
try {
const previous = structuredSessionSelectionController(remote, sessionId, 'codex')
previous.issue('model', 'model-1', null)
expect(retained.size).toBe(1)
const survivor = structuredSessionSelectionController(local, sessionId, 'codex')
survivor.issue('model', 'surviving-model', null)
expect(retained.size).toBe(2)
const reply = Promise.withResolvers<{ kind: 'accepted'; options: Record<string, string> }>()
previous!.flush(() => previous!.request(() => reply.promise))
const drain = previous!.drain()
await retireStructuredAgentSessionTab({ target: remote, worktreeId: 'wt-1', sessionId })
await drain
expect(retained.size).toBe(1)
expect(survivor.overlay()).toEqual({ model: 'surviving-model' })
// The host can still finish the accepted write; no obsolete client owner remembers it.
reply.resolve({ kind: 'accepted', options: { model: 'model-1' } })
await reply.promise
await Promise.resolve()
expect(mocks.remember).not.toHaveBeenCalled()
expect(previous!.overlay()).toEqual({})
const noSend = vi.fn()
await expect(previous!.issue('model', 'dead', null).result).resolves.toEqual({
kind: 'superseded'
})
previous!.flush(noSend)
expect(previous!.freeze()).toEqual([])
await expect(previous!.request(noSend)).rejects.toThrow('retired')
expect(noSend).not.toHaveBeenCalled()
const purged = structuredSessionSelectionController(remote, sessionId, 'codex')
expect(purged).not.toBe(previous)
expect(purged.captureRead()).toEqual([])
purged.issue('effort', 'high', 'sonnet')
retirePurgedTabLaunches(
useAppStore.getState(),
'wt-1',
[],
[
agentSessionTabFixture(sessionId, 'wt-1', {
entityId: sessionId,
executionHostId: 'runtime:selection-generations'
})
],
new Set([sessionId])
)
expect(purged.overlay()).toEqual({})
expect(retained.size).toBe(1)
expect(structuredSessionSelectionController(local, sessionId, 'codex')).toBe(survivor)
expect(survivor.overlay()).toEqual({ model: 'surviving-model' })
survivor.settle(survivor.pending()[0]!, {
kind: 'accepted',
options: { model: 'surviving-model' }
})
expect(mocks.remember).toHaveBeenLastCalledWith(local, {
type: 'apply-picks',
agent: 'codex',
picks: [{ modelId: 'surviving-model', optionId: 'model', value: 'surviving-model' }]
})
console.info(
'selection-registry-lifetime',
JSON.stringify({
ownerScopes: 2,
retainedPerChat: 1,
retainedAfterScopedClose: 1,
retainedAfterScopedPurge: 1
})
)
} finally {
forgetStructuredSessionSelection(sessionId)
added.mockRestore()
deleted.mockRestore()
}
})
@@ -19,6 +19,7 @@ import type { WebSessionIntentOwner } from './web-session-intent-owner'
import { captureWebSessionIntentOwner } from './web-runtime-session-environment'
import { callRuntimeRpc } from './runtime-rpc-client'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
const inFlightRetirements = new Map<string, Promise<boolean>>()
@@ -38,6 +39,7 @@ export function retireStructuredAgentSessionTab(args: {
onError?: (error: unknown) => void
}): Promise<boolean> {
retireStructuredAgentSessionReadOwner(args.sessionId, args.target)
forgetStructuredSessionSelection(args.sessionId, args.target)
const key = retirementKey(args.target, args.worktreeId, args.sessionId)
const existing = inFlightRetirements.get(key)
if (existing) {
@@ -125,7 +125,7 @@ function pendingLaunch(executionHostId: ExecutionHostId, target: RuntimeClientTa
promise: new Promise(() => undefined),
visibilityUnknown: false,
cancelled: false,
selection: { held: {} }
selection: {}
} satisfies StructuredLaunchState)
}
+5
View File
@@ -1,3 +1,4 @@
import { registerStructuredSelectionCatalogStore } from '@/lib/structured-agent-session-selection-controller'
import { canOfferHttpLinkGroupAction } from '@/lib/http-link-in-group-availability'
import { create, type StateCreator } from 'zustand'
import type { AppState } from './types'
@@ -150,6 +151,10 @@ export const useAppStore = create<AppState>()(
)
)
registerStructuredSelectionCatalogStore({
read: useAppStore.getState,
subscribe: useAppStore.subscribe
})
registerHttpLinkStoreAccessor(() => useAppStore.getState(), useAppStore.subscribe)
registerHttpLinkGroupActionAvailability((request) =>
canOfferHttpLinkGroupAction(useAppStore.getState(), request)
@@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { TerminalTab } from '../../../../../../shared/terminal-tab-types'
import type { Tab } from '../../../../../../shared/tab-types'
import type { AppState } from '../../../types'
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start'
import {
getStructuredAgentSessionLaunchOwner,
@@ -44,6 +45,7 @@ export function retirePurgedTabLaunches(
)
const target = structuredAgentSessionTargetForTab(s, tab)
if (target) {
forgetStructuredSessionSelection(tab.entityId, target)
retireStructuredAgentSessionReadOwner(tab.entityId, target)
}
}
@@ -30,7 +30,11 @@ export const AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY =
export const AGENT_SESSION_SEND_USAGE_LIMIT_CONTINUATION_RUNTIME_CAPABILITY =
'agent-session.send-usage-limit-continuation.v1' as const
export const AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY =
'agent-session.send-selection.v1' as const
export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [
AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_USAGE_LIMIT_CONTINUATION_RUNTIME_CAPABILITY,
AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY,
AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY,

Some files were not shown because too many files have changed in this diff Show More