Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
bfde8bd
fix(native-chat): a chat's model, effort and Fast belong to the chat,…
brennanb2025 Oct 19, 2026
c4da436
fix(native-chat): a new chat's first send still pins the default mode…
brennanb2025 Oct 19, 2026
37dcf23
fix(native-chat): narrow the host catalog before reading whether its …
brennanb2025 Oct 19, 2026
2279958
fix(native-chat): a reopened chat whose saved model was refused recor…
brennanb2025 Oct 19, 2026
9f1c2df
fix(native-chat): a new chat names and pins the default it shows, and…
brennanb2025 Oct 19, 2026
9c1e989
test(native-chat): give the no-Fast model fixture its required defaul…
brennanb2025 Oct 19, 2026
715825f
fix(native-chat): an open chat's model pick leaves reconciling to the…
brennanb2025 Oct 19, 2026
2ce8c3c
fix(native-chat): a pick reply only adds the values it names
brennanb2025 Oct 19, 2026
4f3ef91
fix(native-chat): a tier the drawn model doesn't offer shows as Standard
brennanb2025 Oct 19, 2026
cc477af
fix(native-chat): a never-sent chat names and pins its default after …
brennanb2025 Oct 19, 2026
cfc2a14
fix(native-chat): narrow the missing record as null and type the new-…
brennanb2025 Oct 19, 2026
936621c
test(native-chat): the unloaded-turn context test's record store answ…
brennanb2025 Oct 19, 2026
0084b38
fix(native-chat): a chat's speed shows what its next turn runs
brennanb2025 Oct 19, 2026
daa9dc9
fix(native-chat): one speed rule for picker, host and turns; never-se…
brennanb2025 Oct 19, 2026
f4fbd03
test(native-chat): type the resting journal mock and cut overlapping …
brennanb2025 Oct 19, 2026
2485af3
test(native-chat): cut option assertions the joined flows already pin
brennanb2025 Oct 19, 2026
8886f2f
fix(native-chat): an at-rest pick reply and answer name the speed the…
brennanb2025 Oct 19, 2026
c6fe78f
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-opt…
brennanb2025 Oct 19, 2026
e746e44
fix(native-chat): a reopened chat with no saved model names its defau…
brennanb2025 Oct 19, 2026
b6d6610
fix(native-chat): a chat with no saved model keeps the model its firs…
brennanb2025 Oct 19, 2026
e43d919
fix(native-chat): a chat with no saved choices keeps the model and ef…
brennanb2025 Oct 19, 2026
ca68351
fix(native-chat): key what a child ran by its provider child type
brennanb2025 Oct 19, 2026
73a97c6
fix(native-chat): record each option a turn ran where none is saved, …
brennanb2025 Oct 19, 2026
5099c4e
fix(native-chat): save only options a turn really ran, and only ones …
brennanb2025 Oct 19, 2026
98ac347
fix(native-chat): an attach that founds a chat admits its options aga…
brennanb2025 Oct 19, 2026
3631abf
fix(native-chat): Claude and Codex always name what a turn ran, and p…
brennanb2025 Oct 19, 2026
3199666
test(native-chat): Codex admission tests accept the dispatch's run ev…
brennanb2025 Oct 19, 2026
ef0212c
fix(native-chat): a new chat's options are admitted against the folde…
brennanb2025 Oct 19, 2026
60a58ea
fix(native-chat): a new chat's admission reads the list its picker re…
brennanb2025 Oct 19, 2026
1c2e087
chore: re-run CI against main's formatting fix (#30918)
brennanb2025 Oct 20, 2026
105b284
chore: re-run CI against main's formatting fix (#30948)
brennanb2025 Oct 20, 2026
b93993a
test(native-chat): explain the seed-options stub host cast
brennanb2025 Oct 20, 2026
e633d20
fix(native-chat): attach keeps its two-argument call when there are n…
brennanb2025 Oct 20, 2026
0f7ee70
Revert "fix(native-chat): attach keeps its two-argument call when the…
brennanb2025 Oct 20, 2026
d0a8ab5
test(native-chat): create-path attach assertions check the params arg…
brennanb2025 Oct 20, 2026
8f1d4a6
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-opt…
brennanb2025 Oct 20, 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
4 changes: 2 additions & 2 deletions mobile/src/session/use-mobile-host-model-catalog-upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, type MutableRefObject } from 'react'
import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire'
import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
import type { NativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state'
import type { StructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state'
import {
applyStructuredAgentSessionModelCatalog,
settleStructuredAgentSessionBuiltinCatalog,
Expand Down Expand Up @@ -31,7 +31,7 @@ export function useMobileHostModelCatalogUpgrade(args: {
worktree?: string
fence: number | null
optionCatalog: AgentSessionOptionCatalog | null
activeOptionRecordRef: MutableRefObject<NativeChatSessionOptionRecord>
activeOptionRecordRef: MutableRefObject<StructuredAgentSessionOptionRecord>
/** A listing landed after the chat's options were read, which answered without it. */
onListingLanded?: () => void
updateOptionState: (
Expand Down
71 changes: 71 additions & 0 deletions mobile/src/session/use-mobile-structured-agent-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ type ProbeProps = {
sessionId: string | null
fence: number | null
mutate: StructuredAgentSessionMutate
optionStamp?: string
onRender: (controller: Controller) => void
}

Expand All @@ -158,6 +159,7 @@ function Probe(props: ProbeProps): null {
enabled: true,
fence: props.fence,
mutate: props.mutate,
...(props.optionStamp ? { optionStamp: props.optionStamp } : {}),
hostSupport: { permissionModes: true }
})
)
Expand Down Expand Up @@ -671,3 +673,72 @@ describe('useMobileStructuredAgentOptions host catalog', () => {
await harness.unmount()
})
})

describe('useMobileStructuredAgentOptions after a lost pick reply', () => {
const tiers = (value: string, label: string) => [{ value, label }]
const answer = (model: string, serviceTier: string): AgentSessionOptionsResult => ({
models: [
{
id: 'm1',
label: 'M1',
isDefault: true,
efforts: [],
serviceTiers: tiers('priority', 'Fast')
},
{ id: 'm2', label: 'M2', isDefault: false, efforts: [], serviceTiers: tiers('flex', 'Flex') }
],
listedModelIds: ['m1', 'm2'],
current: { model, serviceTier, confirmed: ['model'] }
})
const speed = (controller: Controller) => {
const kind = descriptorFor(controller.optionSnapshot, 'serviceTier')?.kind
return kind?.type === 'select'
? { value: kind.currentValue, choices: kind.choices.map((choice) => choice.value) }
: null
}

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('offline')),
() => pendingRead.promise,
() => Promise.resolve(answer('m2', 'flex'))
]
const client = optionsClient(() => reads.shift()!())
const { mutate } = recordingMutate(async () => ({ status: 'unknown' }))
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
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()

// The status-triggered read fails: 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(4)
await harness.unmount()
})
})
20 changes: 16 additions & 4 deletions mobile/src/session/use-mobile-structured-agent-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../../../src/shared/structured-agent-session-options'
import {
structuredAgentSessionOptionView,
structuredAgentSessionSendSelection,
structuredAgentSessionShownSnapshot
} from '../../../src/shared/structured-agent-session-option-view'
import {
Expand Down Expand Up @@ -151,7 +152,9 @@ export function useMobileStructuredAgentOptions(args: {
setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] })
updateOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionOptions(current, optionCatalog, result)
? applyStructuredAgentSessionOptions(current, optionCatalog, result, {
newLaunch: createdHere !== undefined
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: current
)
}
Expand All @@ -162,6 +165,7 @@ export function useMobileStructuredAgentOptions(args: {
}
}, [
client,
createdHere,
enabled,
optionCatalog,
optionStamp,
Expand Down Expand Up @@ -269,7 +273,9 @@ export function useMobileStructuredAgentOptions(args: {
if (isCurrent()) {
updateOptionState((latest) =>
latest.record === targetRecord
? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed)
? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed, {
newLaunch: createdHere !== undefined
})
: latest
)
}
Expand All @@ -286,9 +292,14 @@ export function useMobileStructuredAgentOptions(args: {
}
}
},
[agent, client, mutate, optionCatalog, sessionId, updateOptionState]
[agent, client, createdHere, mutate, optionCatalog, sessionId, updateOptionState]
)

// Read, not rendered: what the chat holds when the send goes out.
const sendSelection = useCallback(
() => structuredAgentSessionSendSelection(optionStateRef.current),
[]
)
const { optionPickerRequest, invokeStructuredOption, optionSurface } =
useMobileStructuredOptionSurface({
optionSnapshot,
Expand All @@ -308,6 +319,7 @@ export function useMobileStructuredAgentOptions(args: {
optionSurface,
pendingOptionId: optionState.pendingId,
setStructuredOption,
invokeStructuredOption
invokeStructuredOption,
sendSelection
}
}
2 changes: 1 addition & 1 deletion mobile/src/session/use-mobile-structured-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export function useMobileStructuredAgentSession(
commandPending: commandPendingRef,
controller: sendController,
onSendError,
carriesOptions: hostSupport?.sendOptions === true
sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined
})
const turnIndicator = useMemo(
() => ({
Expand Down
16 changes: 6 additions & 10 deletions mobile/src/session/use-mobile-structured-send-with-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-age
import type { MobileStructuredSends } from './use-mobile-structured-sends'
import { structuredSessionOperationId } from './structured-session-operation-id'
import { timeoutForDeadline } from './mobile-structured-agent-session-rpc'
import { structuredAgentSessionSendSelection } from '../../../src/shared/structured-agent-session-option-view'
import { nativeChatTextWithFileReferences } from './mobile-native-chat-file-attachment'
import {
actionableStructuredApproval,
Expand Down Expand Up @@ -64,8 +63,8 @@ export function useMobileStructuredSendWithOutcome(args: {
'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands'
>
onSendError: MobileNativeChatSendErrorReporter
/** Each send carries the model and effort the picker shows; an older host takes neither. */
carriesOptions?: boolean
/** The chat's own model and effort each send carries; absent for an older host, which takes neither. */
sendOptions?: () => Readonly<Record<string, string>> | undefined
}): (
text: string,
images?: string[],
Expand All @@ -83,7 +82,7 @@ export function useMobileStructuredSendWithOutcome(args: {
hold,
onSendError,
queueCapable,
carriesOptions,
sendOptions,
sessionId,
stateRef,
stopping
Expand Down Expand Up @@ -157,11 +156,8 @@ export function useMobileStructuredSendWithOutcome(args: {
queuedAt: Date.now(),
...(stopping ? { sentWhileStopping: true as const } : {})
}
// A pick on its way holds the send, so the picker shows what the chat holds.
const options =
carriesOptions && agent
? structuredAgentSessionSendSelection(agent, controller.snapshot)
: undefined
// A pick on its way holds the send, so the chat's values are what it holds.
const options = sendOptions?.()
const outcome = await sendMobileStructuredAgentSessionMessage({
client,
sessionId,
Expand Down Expand Up @@ -194,7 +190,7 @@ export function useMobileStructuredSendWithOutcome(args: {
hold,
onSendError,
queueCapable,
carriesOptions,
sendOptions,
sessionId,
stateRef,
stopping
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-adapter-capture-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ async function replayCapture(name: string): Promise<Replay> {
requestedAt: nowMs,
fence: 7
})
expect(outcome).toEqual({ state: 'admitted' })
expect(outcome).toMatchObject({ state: 'admitted' })
const sent = claude.connections[0]!.sent.at(-1)?.uuid
if (typeof sent !== 'string' || before.has(sent)) {
throw new Error('replay could not read the dispatched uuid')
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-command-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ async function replayCapture(
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: event.text }] },
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
).resolves.toMatchObject({ state: 'admitted' })
uuidMap.set(event.sentUuid, String(connection.sent.at(-1)!.uuid))
} else if (event.kind === 'control') {
const answerAt = capture.findIndex(
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-fold-steer-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async function riggedAdapter(
requestedAt: nowMs,
fence: 7
})
expect(outcome).toEqual({ state: 'admitted' })
expect(outcome).toMatchObject({ state: 'admitted' })
const sentUuid = claude.connections[0]!.sent.at(-1)?.uuid
if (typeof sentUuid !== 'string') {
throw new Error('the fake connection recorded no sent uuid')
Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-structured-conversation-stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ async function written(claude: ReturnType<typeof fakeClaude>) {
body: USER_MESSAGE,
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
).resolves.toMatchObject({ state: 'admitted' })
return adapter
}

Expand Down Expand Up @@ -95,7 +95,7 @@ describe('Claude Stop that names no turn', () => {
body: USER_MESSAGE,
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
).resolves.toMatchObject({ state: 'admitted' })
expect(connection.sent).toHaveLength(2)
})
})
44 changes: 41 additions & 3 deletions src/main/claude/claude-structured-effort-default-at-rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode
import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations'
import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read'
import { structuredAgentSessionSendSelection } from '../../shared/structured-agent-session-option-view'
import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog'
import {
Expand Down Expand Up @@ -139,11 +140,15 @@ async function startChild(
return adapter
}

function restingRecord(options: Record<string, string>): AgentSessionRecord {
function restingRecord(
options: Record<string, string>,
providerHandleChain: { origin: string }[] = []
): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
return {
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME },
providerHandleChain,
location: { executionHostId: 'local', wslDistro: null },
// Why released: a chat at rest has no live claim, so its next start reads the current account.
lease: { claimStatus: 'released' },
Expand Down Expand Up @@ -174,7 +179,13 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
const resting = {
child: null,
params: { provider: 'claude' },
journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } }
journal: {
threadGoal: () => null,
visitItems: () => {},
submissions: () => [],
contextUsage: () => null,
context: { floor: () => null }
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
const context = {
Expand Down Expand Up @@ -232,6 +243,30 @@ describe('Claude effort default at rest', () => {
expect(pickerEffort(result)).toBe('medium')
})

it('names the default for a reopened chat with no saved model, and its run reports that row', async () => {
const store = new AgentModelCatalogStore()
const child = await startChild(store)
const seed = getAgentSessionOptionCatalog('claude')!
const picker = (answer: Awaited<ReturnType<typeof readAtRest>>) => {
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('claude', seed),
seed,
answer
)
const row = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === 'model')
const shown = row?.kind.type === 'select' ? row.kind.currentValue : undefined
return { shown, sends: structuredAgentSessionSendSelection(state)?.model }
}
const reopened = await readAtRest(store, restingRecord({}, [{ origin: 'adopted' }]))
expect(reopened.current).toMatchObject({ model: 'opus[1m]', defaulted: ['model', 'effort'] })
expect(picker(reopened)).toEqual({ shown: 'opus[1m]', sends: undefined })

// Its first turn runs what the CLI reports (claude-opus-5-5[1m]), as that row.
const ran = (await child.readOptions({ sessionId: SESSION, fence: 7 })).current
expect(ran).toMatchObject({ model: 'opus[1m]' })
expect(ran.defaulted ?? []).not.toContain('model')
})

it("shows the user's pick at rest over the default", async () => {
const store = new AgentModelCatalogStore()
await startChild(store)
Expand Down Expand Up @@ -282,6 +317,7 @@ describe('Claude effort default at rest', () => {
const record = {
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' },
providerHandleChain: [],
location: { executionHostId: 'local', wslDistro: null },
options: { model: 'gpt-unlisted', effort: 'high' }
} as unknown as AgentSessionRecord
Expand Down Expand Up @@ -364,6 +400,7 @@ describe('Claude effort default at rest', () => {
const record = {
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' },
providerHandleChain: [],
location: { executionHostId: 'local', wslDistro: null },
options: { model: 'gpt-5.5', effort: 'high' }
} as unknown as AgentSessionRecord
Expand All @@ -381,6 +418,7 @@ describe('Claude effort default at rest', () => {
const record = {
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' },
providerHandleChain: [],
location: { executionHostId: 'local', wslDistro: null },
options: { model: 'gpt-5.5' }
} as unknown as AgentSessionRecord
Expand All @@ -407,7 +445,7 @@ describe('Claude effort default at rest', () => {

const result = await readAtRest(store, record)

expect(result.current).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' })
expect(result.current).toEqual({ model: 'gpt-5.5', defaulted: [], permissionMode: 'ask' })
})

it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => {
Expand Down
Loading
Loading