Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
c4471b1
Capture message choices per accepted send and preserve picker state
brennanb2025 Oct 13, 2026
e24895d
Apply accepted Claude message choices at prompt handover
brennanb2025 Oct 13, 2026
e0b92fc
Apply captured message choices at provider dispatch
brennanb2025 Oct 13, 2026
323f070
Bind native chat choices to accepted message dispatch
brennanb2025 Oct 13, 2026
b564887
Reset Codex turn choices for each captured message
brennanb2025 Oct 13, 2026
d3fa3ff
Resolve Codex message defaults from native facts
brennanb2025 Oct 13, 2026
4542252
Align message choice and Pi default fixtures with provider dispatch
brennanb2025 Oct 13, 2026
21eda73
Align option fixtures with saved message choices
brennanb2025 Oct 13, 2026
9c11b4d
Match Codex message choices to the active turn origin
brennanb2025 Oct 13, 2026
0d0df2b
fix: let pending commands reach handover refusal
brennanb2025 Oct 13, 2026
ac79c22
Abort pre-prompt message choice writes with host-owned wait
brennanb2025 Oct 13, 2026
b2913b2
Verify message choices through provider input boundaries
brennanb2025 Oct 13, 2026
e200450
Keep legacy mobile test hosts explicit about message choices
brennanb2025 Oct 13, 2026
a7dd133
Carry startup and signed-out recovery fixes into message choices
brennanb2025 Oct 13, 2026
955f83c
Preserve message choices across cold clients and kept cards
brennanb2025 Oct 13, 2026
9d553b1
Remove the unused provider option restore ledger
brennanb2025 Oct 13, 2026
e15f2f3
Clarify launch choices and legacy option hydration
brennanb2025 Oct 13, 2026
38a8258
Remove remaining restore-ledger forwarding and assertions
brennanb2025 Oct 13, 2026
331146c
Remove unused ACP and Pi restore bookkeeping
brennanb2025 Oct 13, 2026
898ee48
Carry delivery ordering fixes into per-message choices
brennanb2025 Oct 13, 2026
ce3deb5
Check running choices and retained rewind messages after delivery int…
brennanb2025 Oct 13, 2026
7a2dfe5
Carry startup ordering fixes into per-message choices [skip ci]
brennanb2025 Oct 13, 2026
03bc334
Cover goal changes during the held Codex message wait [skip ci]
brennanb2025 Oct 13, 2026
1ff2656
Carry startup picks through an ended opening [skip ci]
brennanb2025 Oct 13, 2026
9d9d045
Carry main sync into 28207
brennanb2025 Oct 13, 2026
238a3d5
Carry main sync into 28207
brennanb2025 Oct 13, 2026
5acb99f
Carry main sync into 28207
brennanb2025 Oct 13, 2026
59267e1
Check the saved picker selection in per-message choice fixtures
brennanb2025 Oct 13, 2026
bbe11a9
Exercise current choices and eager client capabilities in runtime fix…
brennanb2025 Oct 13, 2026
ce99d7a
Reuse runtime fixture reads and type the fake connection directly
brennanb2025 Oct 13, 2026
e879a36
Carry main sync into 28207
brennanb2025 Oct 13, 2026
7364de7
Honor queued send authorization in Claude runtime fixtures
brennanb2025 Oct 13, 2026
c21b770
Carry Codex fixture opening into message choices
brennanb2025 Oct 13, 2026
e52ff1a
Carry main sync into 28207
brennanb2025 Oct 13, 2026
35ee605
Keep repeated permission picks usable and align Codex fixtures
brennanb2025 Oct 13, 2026
466dc12
Validate pending picks against the live option state
brennanb2025 Oct 13, 2026
a930eb0
Carry startup ordering while preserving captured message choices
brennanb2025 Oct 13, 2026
bb5346f
Carry main sync into 28207
brennanb2025 Oct 13, 2026
af5edab
Keep message choice flows and remove overlapping selector tests
brennanb2025 Oct 13, 2026
6ffa3cb
Carry startup picker fix into saved message choices
brennanb2025 Oct 13, 2026
fe7ed3a
Match startup picker fixture to saved message choices
brennanb2025 Oct 13, 2026
f66f3e1
Carry current main and startup corrections into message choices
brennanb2025 Oct 14, 2026
2979f93
Check startup effort after saved picker choices are acknowledged
brennanb2025 Oct 14, 2026
1b80d3f
Complete Stop target types and keep the effort regression concise
brennanb2025 Oct 14, 2026
24dcf69
Use requested picker values in the effort regression acknowledgement
brennanb2025 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts',
Expand All @@ -137,6 +138,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts',
Expand Down Expand Up @@ -167,6 +169,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const HOST_SUPPORT = {
queuedMessages: true,
queuedCommands: false,
statusFeed: true,
messageChoices: false,
quietRepeatedStop: true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
Expand All @@ -15,6 +16,7 @@ describe('structuredAgentSessionHostSupport', () => {
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
messageChoices: false,
queuedCommands: false,
quietRepeatedStop: false,
statusFeed: false
Expand All @@ -29,6 +31,9 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY])
).toEqual({ ...none, queuedMessages: true })
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY])
).toEqual({ ...none, messageChoices: true })
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY])
).toEqual({ ...none, queuedCommands: true })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
Expand All @@ -13,6 +14,7 @@ export type StructuredAgentSessionHostSupport = {
questionAnswers: boolean
/** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */
queuedMessages: boolean
messageChoices: boolean
/** A /compact sent while the agent works waits as a card; an older host refuses it. */
queuedCommands: boolean
/** The host publishes every session's status on one stream. A host from before phones could
Expand All @@ -29,6 +31,7 @@ export function structuredAgentSessionHostSupport(
promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY),
questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY),
queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY),
messageChoices: capabilities.includes(AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY),
queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY),
statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY)
Expand Down
6 changes: 5 additions & 1 deletion mobile/src/session/mobile-structured-agent-session-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
sessionId: string
expectedRuntimeFence: number
fields: Record<string, unknown>
/** Sent beside the request, excluded from the operation fingerprint. */
unfingerprintedFields?: Record<string, unknown>
clientOperationId?: string
timeoutMs?: number
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
Expand All @@ -135,6 +137,7 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
sessionId,
expectedRuntimeFence,
fields,
unfingerprintedFields,
clientOperationId,
timeoutMs
} = args
Expand All @@ -153,7 +156,8 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
fields
})
},
...fields
...fields,
...unfingerprintedFields
},
timeoutMs
)
Expand Down
5 changes: 5 additions & 0 deletions mobile/src/session/mobile-structured-agent-session-send.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire'
import type { AgentSessionExecutionOptions } from '../../../src/shared/agent-session-execution-options'
import {
structuredAgentSessionSendBody,
type StructuredAgentSessionAttachment
Expand All @@ -20,6 +21,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
attachments: readonly StructuredAgentSessionAttachment[]
/** Sent only when the host advertises `agent-session.queued-messages.v1`. */
delivery?: 'queue-if-active'
executionOptions?: AgentSessionExecutionOptions
deadline?: number
/** The id the host records the message under. */
clientMessageId: string
Expand All @@ -41,6 +43,9 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
body: structuredAgentSessionSendBody(input.text, input.attachments),
...(input.delivery ? { delivery: input.delivery } : {})
},
...(input.executionOptions
? { unfingerprintedFields: { executionOptions: input.executionOptions } }
: {}),
clientOperationId: input.clientMessageId,
timeoutMs
})
Expand Down
27 changes: 27 additions & 0 deletions mobile/src/session/mobile-structured-option-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createNativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import {
commitStructuredAgentSessionOptionValues,
structuredAgentSessionOptionSnapshot,
type StructuredAgentSessionOptionState
} from '../../../src/shared/structured-agent-session-options'

/** The host's saved choice with this phone's still-pending pick on top. */
export function mobileStructuredOptionSnapshot(
agent: string,
state: StructuredAgentSessionOptionState,
hostSelection: Readonly<Record<string, string>> | undefined
): SessionOptionDescriptor[] {
const selected = hostSelection
? commitStructuredAgentSessionOptionValues(
{ ...state, record: createNativeChatSessionOptionRecord(agent) },
hostSelection
)
: state
const pending = state.pendingPick
return structuredAgentSessionOptionSnapshot(
pending
? commitStructuredAgentSessionOptionValues(selected, { [pending.id]: pending.value })
: selected
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
Expand All @@ -9,7 +9,10 @@ import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-o

const HOST_LIST: AgentSessionModelCatalogResult = {
origin: 'probe',
models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }],
models: [
{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] },
{ id: 'gpt-alt', label: 'GPT Alt', isDefault: false, efforts: [] }
],
fetchedAt: 1_000
}

Expand All @@ -36,21 +39,27 @@ function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] {
return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : []
}

type Props = { rpc: RpcClient; fence: number }
type Props = { rpc: RpcClient; fence: number; hostSelection?: Readonly<Record<string, string>> }

async function mount(initial: Props) {
const frames: string[][] = []
const mutate: StructuredAgentSessionMutate = vi.fn()
const snapshots: SessionOptionDescriptor[][] = []
const mutate: StructuredAgentSessionMutate = async () => new Promise(() => {})
let pick: ReturnType<typeof useMobileStructuredAgentOptions>['setStructuredOption'] = async () =>
false
function Probe(props: Props): null {
const options = useMobileStructuredAgentOptions({
agent: 'codex',
client: props.rpc,
sessionId: 'session-1',
enabled: true,
fence: props.fence,
hostSelection: props.hostSelection,
mutate
})
frames.push(modelChoices(options.optionSnapshot))
snapshots.push(options.optionSnapshot)
pick = options.setStructuredOption
return null
}
let renderer: ReactTestRenderer | null = null
Expand All @@ -59,6 +68,8 @@ async function mount(initial: Props) {
})
return {
frames,
snapshots,
pick: (id: string, value: string) => pick(id, value),
update: (next: Props) =>
act(async () => {
renderer?.update(createElement(Probe, next))
Expand All @@ -74,10 +85,10 @@ describe('the phone picker’s first frame', () => {
const harness = await mount({ rpc, fence: 1 })
expect(harness.frames.every((choices) => choices.length === 0)).toBe(true)
await act(async () => answer(HOST_LIST))
expect(harness.frames.at(-1)).toEqual(['gpt-host'])
expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt'])
// Only the host's list was ever offered.
expect(harness.frames.filter((choices) => choices.length > 0)).toEqual(
harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host'])
harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host', 'gpt-alt'])
)
await harness.unmount()
})
Expand All @@ -86,7 +97,7 @@ describe('the phone picker’s first frame', () => {
let reads = 0
const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {})))
const harness = await mount({ rpc, fence: 1 })
expect(harness.frames.at(-1)).toEqual(['gpt-host'])
expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt'])
const before = harness.frames.length
await harness.update({ rpc, fence: 2 })
expect(harness.frames.slice(before).every((choices) => choices[0] === 'gpt-host')).toBe(true)
Expand All @@ -99,4 +110,23 @@ describe('the phone picker’s first frame', () => {
expect(harness.frames.at(-1)?.length).toBeGreaterThan(0)
await harness.unmount()
})

it('uses the host’s live selection when the options read has not answered', async () => {
const rpc = client(() => Promise.resolve(HOST_LIST))
const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } })
const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model')
expect(model?.kind.currentValue).toBe('gpt-host')
await harness.unmount()
})

it('includes a pick still waiting for the host in the next message snapshot', async () => {
const rpc = client(() => Promise.resolve(HOST_LIST))
const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } })
await act(async () => {
void harness.pick('model', 'gpt-alt')
})
const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model')
expect(model?.kind.currentValue).toBe('gpt-alt')
await harness.unmount()
})
})
22 changes: 9 additions & 13 deletions mobile/src/session/use-mobile-structured-agent-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
mobileCreatedStructuredSession
} from './mobile-created-structured-sessions'
import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec'
import { mobileStructuredOptionSnapshot } from './mobile-structured-option-snapshot'

type StructuredOptionsController = {
optionPickerRequest: { id: string; sequence: number } | null
Expand All @@ -56,8 +57,9 @@ export function useMobileStructuredAgentOptions(args: {
mutate: StructuredAgentSessionMutate
/** Moves when the host's status says a session option changed; each move re-reads them. */
optionStamp?: string
hostSelection?: Readonly<Record<string, string>>
}): StructuredOptionsController {
const { agent, client, enabled, fence, mutate, optionStamp, sessionId } = args
const { agent, client, enabled, fence, mutate, optionStamp, hostSelection, sessionId } = args
// Every agent's seed, as on the desktop: a built-in list or the provider-default pill.
const optionCatalog = useMemo(
() => (agent ? structuredAgentSessionSeedCatalog(agent) : null),
Expand All @@ -68,7 +70,6 @@ export function useMobileStructuredAgentOptions(args: {
)
const optionStateRef = useRef(optionState)
const activeOptionRecordRef = useRef(optionState.record)
const pendingOptionRef = useRef<string | null>(null)
const optionMutationGeneration = useRef(0)
const updateOptionState = useCallback(
(update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => {
Expand Down Expand Up @@ -106,7 +107,6 @@ export function useMobileStructuredAgentOptions(args: {
// The chat's own, which a restart for a new mode keeps: the pill never blanks between them.
const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept
optionMutationGeneration.current += 1
pendingOptionRef.current = null
optionStateRef.current = next
activeOptionRecordRef.current = next.record
setOptionState(next)
Expand Down Expand Up @@ -173,16 +173,15 @@ export function useMobileStructuredAgentOptions(args: {
])

const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
[optionState]
() => mobileStructuredOptionSnapshot(agent ?? 'codex', optionState, hostSelection),
[agent, hostSelection, optionState]
)

const setStructuredOption = useCallback(
async (id: string, value: SessionOptionValue): Promise<boolean> => {
const currentState = optionStateRef.current
const encoded = encodeStructuredAgentSessionOptionValue(id, value)
if (
pendingOptionRef.current !== null ||
!client ||
!sessionId ||
!optionCatalog ||
Expand All @@ -204,10 +203,8 @@ export function useMobileStructuredAgentOptions(args: {
? commitStructuredAgentSessionOption(current, id, encoded)
: current
)
} else {
pendingOptionRef.current = id
updateOptionState((current) => ({ ...current, pendingId: id }))
}
updateOptionState((current) => ({ ...current, pendingPick: { id, value: encoded } }))
// A same-fence accepted write, or a shown one that failed, reads back what the host now holds.
let readsBack = shownAtOnce
try {
Expand Down Expand Up @@ -266,10 +263,9 @@ export function useMobileStructuredAgentOptions(args: {
.catch(() => undefined)
}
if (isCurrent()) {
pendingOptionRef.current = null
updateOptionState((current) =>
current.record === targetRecord && current.pendingId === id
? { ...current, pendingId: null }
current.record === targetRecord && current.pendingPick?.id === id
? { ...current, pendingPick: null }
: current
)
}
Expand Down Expand Up @@ -316,7 +312,7 @@ export function useMobileStructuredAgentOptions(args: {
(agent !== null && isAgentSessionHandleProvider(agent)),
optionSnapshot,
optionSurface,
pendingOptionId: optionState.pendingId,
pendingOptionId: optionState.pendingPick?.id ?? null,
setStructuredOption,
invokeStructuredOption
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function Harness({
queuedMessages: false,
queuedCommands: false,
statusFeed: false,
messageChoices: false,
quietRepeatedStop: false
},
onSendError: vi.fn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = {
queuedMessages: true,
queuedCommands: false,
statusFeed: false,
messageChoices: false,
quietRepeatedStop: false
}
export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false }
Expand Down
Loading
Loading