Floor the structured option catalog on the seed the client already holds

`structuredAgentSessionOptionCatalog` runs on the client, over a result a host of
any version published — both `use-structured-agent-session.ts` and mobile's
`use-mobile-structured-agent-options.ts` hand it straight off the
`agentSession.options` wire. It mapped `result.models` through unchanged, so an
empty list reached the snapshot, which correctly returns `[]` for an empty
catalog, and the model pill and effort pill both vanished.

On `origin/main` that was unreachable: this function unconditionally pushed
`current.model`, so the list was never empty. Withholding unofficial ids removed
that push, so the exposure is this PR's. Flooring only the host-side readers
closes it for new hosts; a new client paired with an older host still hits it,
which is the divergence `docs/reference/remote-wire-compatibility.md` covers
when a host changes what it publishes.

The floor uses `seed.models`, the catalog the client already passes in, so it
holds for Claude and any future structured agent rather than one provider. The
reader-side floors stay as defence in depth.
This commit is contained in:
Merge Sim
2026-09-09 13:47:35 -07:00
parent 1028d76add
commit f4a75f68b9
2 changed files with 37 additions and 2 deletions
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
} from './agent-session-option-catalog-claude-codex'
import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot'
import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state'
import {
@@ -93,6 +96,33 @@ describe('structured agent session options', () => {
})
})
it.each([
{ agent: 'codex' as const, seed: CODEX_SESSION_OPTION_CATALOG },
{ agent: 'claude' as const, seed: CLAUDE_SESSION_OPTION_CATALOG }
])(
'keeps the options row when an older host publishes an empty list: $agent',
({ agent, seed }) => {
// Wire case, not a unit case: `structuredAgentSessionOptionCatalog` runs on the client over
// whatever a host published. A host that predates the readers' own seed floor still sends
// `models: []` beside a current model for a restored thread, and the row must survive it.
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState(agent),
seed,
{ models: [], current: { model: 'unlisted-from-an-old-host' } }
)
const snapshot = structuredAgentSessionOptionSnapshot(state)
expect(snapshot.map((descriptor) => descriptor.id)).toEqual(['model', 'effort'])
const model = snapshot[0]!
// Only official names reach the pill; the raw id is neither offered nor shown.
expect(model).toMatchObject({ valueSource: 'unknown' })
expect(model.kind.type === 'select' ? model.kind.choices.map((c) => c.value) : []).toEqual(
seed.models.map((seeded) => seeded.id)
)
expect(snapshot[1]).toMatchObject({ id: 'effort', settable: true, kind: { type: 'select' } })
}
)
it('projects live options as directly settable descriptors', () => {
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex'),
@@ -52,7 +52,12 @@ export function structuredAgentSessionOptionCatalog(
// Why: only ids the provider lists may be offered. An unlisted `current.model` still
// reaches the snapshot through the record, which draws it as tracked-but-unmatched —
// a neutral pill plus effort from `unknownModelOptions` — instead of a raw-id row.
const models: CatalogModel[] = result.models.map(discoveredModel)
const discovered: CatalogModel[] = result.models.map(discoveredModel)
// Why here and not only in each host-side reader: this runs on the client, on a result a host
// of any version published. An empty list offers nothing to pick and carries no options, so the
// snapshot drops the whole row — and a host that predates the readers' own seed floor publishes
// exactly that for a restored thread whose provider listed nothing. The seed is agent-agnostic.
const models = discovered.length > 0 ? discovered : [...seed.models]
return { ...seed, models, defaultModelIsCliDefault: true }
}