diff --git a/AGENTS.md b/AGENTS.md index 13d91b4ea..1a729666a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Each of these breaks the product, a release, or the build with **no loud error**. -1. **Wire-protocol versions are lockstep.** `WIRE_PROTOCOL_VERSION` (currently 41) is a `z.literal` in `packages/foundation/schema/src/wire`; any change to any wire variant must bump it. Mismatched peers still complete the socket ("connected") but every frame fails validation and is **silently discarded** — zero messages, a hang-like failure. Rebuild and restart the daemon and every client together. +1. **Wire-protocol versions are lockstep.** `WIRE_PROTOCOL_VERSION` (currently 44) is a `z.literal` in `packages/foundation/schema/src/wire`; any change to any wire variant must bump it. Mismatched peers still complete the socket ("connected") but every frame fails validation and is **silently discarded** — zero messages, a hang-like failure. Rebuild and restart the daemon and every client together. 2. **`foxts/once` prewarms by default.** `once(fn)` runs `fn` immediately at construction and caches the result; call-at-most-once semantics need `once(fn, false)`. The default has already shipped a daemon that ran its shutdown at boot and transports whose close-callback fired at construction. Read any foxts helper's `.d.ts`/source before adopting it — the lodash-alike name lies. 3. **Native deps must be allow-listed.** pnpm blocks install scripts by default; a native dep (e.g. `better-sqlite3`) missing from `allowBuilds:` in `pnpm-workspace.yaml` installs fine but fails at `require()` time with missing bindings. 4. **`check:ci` does not run `pnpm test`.** CI runs vitest as a separate TypeScript-job step, while `check:ci` remains format/lint/typecheck only. Run both commands before every commit; passing either one alone is not the complete JavaScript gate. diff --git a/packages/foundation/schema/src/model/agent/event.ts b/packages/foundation/schema/src/model/agent/event.ts index 382b5e1a8..5d75fd273 100644 --- a/packages/foundation/schema/src/model/agent/event.ts +++ b/packages/foundation/schema/src/model/agent/event.ts @@ -104,9 +104,8 @@ export const AgentEventSchema = z.discriminatedUnion('type', [ * learned and on every provider-side change, full-replace semantics. */ z.object({ type: z.literal('available-commands-update'), commands: z.array(AgentCommandSchema) }), /** The model catalog the session accepts via `AgentInput.set-model` — same full-replace contract - * as the command catalog. Only adapters whose model set is install-dependent emit it (opencode); - * agents with a curated static catalog (claude-code/codex) never do, and clients fall back to - * their static tables. */ + * as the command catalog. Adapter catalogs may also carry per-model effort capabilities; clients + * fall back to curated static tables when no catalog is advertised. */ z.object({ type: z.literal('available-models-update'), models: z.array(AgentModelOptionSchema) }), z.object({ type: z.literal('status'), status: SessionStatusSchema }), diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 50a9c0da5..7a4de6d7c 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -34,10 +34,18 @@ export const McpServerSchema = z.discriminatedUnion('type', [ ]); export type McpServer = z.infer; -/** Reasoning-effort levels. low–xhigh switch live; `max` only applies at process startup, so - * adapters honor it by restarting the underlying process and resuming in place. `ultracode` is - * claude-code's xhigh-plus-standing-orchestration mode, modeled as a level; it switches live. */ -export const EffortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']); +/** Normalized reasoning-effort levels. Availability is provider- and model-specific: `max` is + * startup-only for claude-code but a normal per-turn Codex value; Codex `ultra` enables proactive + * multi-agent behavior, while claude-code's distinct `ultracode` mode switches live. */ +export const EffortLevelSchema = z.enum([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', + 'ultracode', +]); export type EffortLevel = z.infer; /** Parameters required to start an agent session. */ @@ -83,15 +91,16 @@ export function agentCommandMatches(command: AgentCommand, name: string): boolea return command.name === name || (command.aliases?.includes(name) ?? false); } -/** A model a live session accepts via `AgentInput.set-model`, advertised by adapters whose model - * set is install-dependent (opencode: whatever providers the user's local install has connected) - * rather than a fixed vendor list. `id` is the exact value to send back on `set-model`. */ +/** A model an adapter accepts via `AgentInput.set-model`, advertised before session start and/or + * on a live session. `id` is the exact value to send back on `set-model`. */ export const AgentModelOptionSchema = z.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional(), /** Absent means unknown; an empty list means this model has no effort axis. */ effortLevels: z.array(EffortLevelSchema).optional(), + /** Provider default for this model, when the catalog advertises one. */ + defaultEffort: EffortLevelSchema.optional(), }); export type AgentModelOption = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index a6ff87dc6..b136541ee 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -13,7 +13,8 @@ import { WirePayloadSchema } from './payload'; // schema it does not speak. // 43 combines 42's agent.catalog/agent.cataloged with CODE-316's parallel 42 bump for // file.host/file.hosted, keeping every distinct schema on a distinct protocol version. -export const WIRE_PROTOCOL_VERSION = 43 as const; +// 44 adds provider/model-specific reasoning-effort metadata and Codex's distinct `ultra` level. +export const WIRE_PROTOCOL_VERSION = 44 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/foundation/schema/tests/contract/wire/agent.test.ts b/packages/foundation/schema/tests/contract/wire/agent.test.ts index b20beba11..7b5251bb2 100644 --- a/packages/foundation/schema/tests/contract/wire/agent.test.ts +++ b/packages/foundation/schema/tests/contract/wire/agent.test.ts @@ -20,6 +20,18 @@ describe('agent wire variants', () => { outcome: { outcome: 'cancelled' }, source: 'session', }, + { type: 'effort-update', effort: 'ultra' }, + { + type: 'available-models-update', + models: [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + defaultEffort: 'low', + }, + ], + }, ])('accepts $type through the complete wire envelope', (event) => { expect( WireMessageSchema.safeParse({ @@ -30,4 +42,25 @@ describe('agent wire variants', () => { }).success, ).toBe(true); }); + + it('accepts Codex ultra as input but rejects provider values outside the normalized vocabulary', () => { + const message = { + v: WIRE_PROTOCOL_VERSION, + id: 'message-1', + ts: 0, + payload: { + kind: 'agent.input', + clientReqId: 'request-1', + sessionId: 'session-1', + input: { type: 'set-effort', effort: 'ultra' }, + }, + }; + expect(WireMessageSchema.safeParse(message).success).toBe(true); + expect( + WireMessageSchema.safeParse({ + ...message, + payload: { ...message.payload, input: { type: 'set-effort', effort: 'minimal' } }, + }).success, + ).toBe(false); + }); }); diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 6ca2ba2b3..832c5d171 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -70,7 +70,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **Approvals**: server→client requests `item/commandExecution|fileChange/requestApproval` answer through the shared `requestPermission` round-trip — Allow→`accept`, Always allow→`acceptForSession` (real session-scoped grant), Reject→`decline`, teardown/cancel→`cancel`. The `declined` item status folds into `failed`, like claude-code's denied tool_result. - **Approval-policy axis** (three tiers; claude-only ids like `plan`/`auto` reject): `default`→untrusted + workspace-write, `acceptEdits` (initial)→on-request + workspace-write, `bypassPermissions`→never + danger-full-access. Applied per `turn/start` — next-turn semantics, same channel as model/effort; nothing can alter an in-flight turn. - **config.toml contract**: `codexConfiguredSandbox()` (`codex/config.ts`; exercised via a throwaway `CODEX_HOME` in `codex-config.test.ts`) reads the active profile's or top-level `sandbox_mode`. Until the user EXPLICITLY picks a tier, a configured sandbox is never overridden — thread/turn requests omit their sandbox override so codex's own resolution wins (NEVER silently loosen a stricter read-only); the preset's approval posture still rides, which is safe now that approvals are answerable. An explicit pick applies the preset exactly, config.toml notwithstanding. Honoring config.toml `approval_policy` stays an unfinished follow-up. -- **set-model / set-effort** are stored and sent as `turn/start` overrides (they stick for subsequent turns). Effort accepts `low`–`xhigh`; `max`/`ultracode` are claude-only and reject. The picker entries are the static UI tables (`agent-models.ts` / `agent-efforts.ts`), each verified live via `turn_context` readback. At startup, the adapter reflects the `thread/start`/`thread/resume` response's model and configured effort; a null effort resolves through that model's `model/list.defaultReasoningEffort` without pinning it as an override. `thread/settings/updated` is the ongoing effective model/effort authority. The rest of the dynamic catalog remains deliberately unused (the CODE-104 attempt was cancelled). +- **set-model / set-effort** are stored and sent as `turn/start` overrides (they stick for subsequent turns). Effort availability comes from each `model/list` entry's `supportedReasoningEfforts`: Sol/Terra advertise `low|medium|high|xhigh|max|ultra`, while Luna omits `ultra`; `minimal` is outside LinkCode's normalized vocabulary and Claude's distinct `ultracode` always rejects. The adapter publishes that per-model catalog both before session start and on the live event stream, and validates startup/live selections against the selected model. If optional `model/list` discovery is unavailable, validation defers to Codex instead of blocking session start. At startup, it reflects the `thread/start`/`thread/resume` response's model and configured effort; a null effort resolves through that model's `model/list.defaultReasoningEffort` without pinning it as an override. `thread/settings/updated` reflects effective provider state unless it conflicts with a newer pending next-turn pick. Switches apply from the next turn, not mid-turn. - **File changes surface structurally**: `fileChange.changes[].diff` (unified) → per-hunk `{type:'diff', path, oldText, newText}` via `diffContentFromUnified`; a rename rides `kind.move_path` and is cited by destination. Command output arrives only at `item/completed` (`aggregatedOutput`). Tool kinds: `fileChange`→edit, `commandExecution`→execute, `webSearch`→fetch, `mcpToolCall`→other, `turn/plan/updated`→ a distinct `{type:'plan'}` event. - **Compaction (CODE-142)**: a `contextCompaction` item (id only — no tokens or summary on the app-server protocol) emits `{type:'compaction'}` with `status:'in_progress'` at item/started and `'completed'` at item/completed (clients merge by `compactionId`; the status field is codex-only — claude-code's boundary events stay status-less = completed). The adapter's `teardown()` override settles a compaction whose item/completed was lost to an interrupt or server death. History replays rollout `compacted` rows (`window_id` → `compactionId`); `payload.message` carries the plain summary ONLY for local compaction — remote compaction (the ChatGPT-account path; verified on real 0.144 rollouts) writes an empty `message` and ships the summary as `{type:'compaction', encrypted_content}` inside `replacement_history`, unrecoverable like reasoning, so those markers replay summary-less. Live item ids and rollout window ids do NOT converge, same as tools. - **Lifecycle races are handled explicitly** (`codex/adapter.ts` field docs are the reference): `turnStartsInFlight` (a COUNT — `turn/completed` can precede the `turn/start` reply, so a drained queue prompt overlaps the settled frame, whose cleanup must not drop the newer frame's guard) gates the send→turn-id window, a cancel inside it is armed and fired the moment the id lands; `lastCompletedTurnId` stops a late `turn/start` response from re-activating a settled turn; an unexpected app-server exit finalizes the turn, re-arms `resumeFrom`, and the next prompt respawns + `thread/resume`s in place. diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts index e51c5bc91..10e254a8e 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts @@ -127,6 +127,14 @@ async function waitIdle(events: AgentEvent[]): Promise { } describe('ClaudeCodeAdapter effort switching', () => { + it('rejects Codex ultra instead of treating it as a Claude flag setting', async () => { + const adapter = new ClaudeCodeAdapter(); + await expect( + adapter.start({ kind: 'claude-code', cwd: '/tmp/repo', effort: 'ultra' }), + ).rejects.toThrow("claude-code: effort 'ultra' is not supported"); + expect(queries).toHaveLength(0); + }); + it('applies initial effort while constructing the first Query', async () => { const { events } = await makeAdapter('high'); const q0 = queries[0]; diff --git a/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts b/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts index 36a8e0897..78221a2ad 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts @@ -5,11 +5,17 @@ import { CodexAdapter } from '../native/codex'; import type { CodexServerHandle } from '../native/codex/adapter'; import type { CodexAppServerOptions } from '../native/codex/app-server'; +function reasoningEfforts(...efforts: string[]) { + return efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })); +} + /** Minimal fake satisfying `CodexServerHandle` — narrower than `normalize.test.ts`'s * `FakeCodexServer` (not exported): a request log, a per-method response hook, and the * notification/exit callbacks the adapter registers through `startAppServer`'s options. */ class FakeCodexServer { readonly requests: Array<{ method: string; params: Record }> = []; + closed = false; + emptyModelList = false; /** Set per test to reject a specific method like a JSON-RPC error response. */ rejectMethod: string | undefined; threadResponse: unknown = { @@ -27,19 +33,47 @@ class FakeCodexServer { return Promise.resolve(this.threadResponse); } if (method === 'model/list') { + if (this.emptyModelList) return Promise.resolve({ data: [] }); return Promise.resolve({ data: [ { id: 'gpt-5.6-terra', model: 'gpt-5.6-terra', + displayName: 'GPT-5.6-Terra', isDefault: false, defaultReasoningEffort: 'medium', + supportedReasoningEfforts: reasoningEfforts( + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', + ), }, { id: 'gpt-5.6-sol', model: 'gpt-5.6-sol', + displayName: 'GPT-5.6-Sol', isDefault: true, defaultReasoningEffort: 'low', + supportedReasoningEfforts: reasoningEfforts( + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', + ), + }, + { + id: 'gpt-5.6-luna', + model: 'gpt-5.6-luna', + displayName: 'GPT-5.6-Luna', + isDefault: false, + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: reasoningEfforts('low', 'medium', 'high', 'xhigh', 'max'), }, ], }); @@ -50,20 +84,28 @@ class FakeCodexServer { // Approvals never fire on the shell-command path. } close(): void { - // Nothing to reap. + this.closed = true; } notify(method: string, params: unknown): void { this.opts.onNotification(method, params); } + exit(stderrTail = 'crashed'): void { + this.closed = true; + this.opts.onExit(1, stderrTail); + } } class TestCodex extends CodexAdapter { fakeServers: FakeCodexServer[] = []; + emptyModelList = false; + rejectMethod: string | undefined; threadResponse: unknown; protected override startAppServer( opts: Omit, ): Promise { const server = new FakeCodexServer(opts); + server.emptyModelList = this.emptyModelList; + server.rejectMethod = this.rejectMethod; if (this.threadResponse !== undefined) server.threadResponse = this.threadResponse; this.fakeServers.push(server); return Promise.resolve(server); @@ -110,6 +152,35 @@ function driveShellTurn( } describe('CodexAdapter shell-command passthrough', () => { + it('advertises normalized per-model effort capabilities before session start', async () => { + const adapter = new TestCodex(); + + await expect(adapter.startCatalog()).resolves.toEqual({ + models: [ + { + id: 'gpt-5.6-terra', + label: 'GPT-5.6-Terra', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + defaultEffort: 'medium', + }, + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + defaultEffort: 'low', + }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6-Luna', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'medium', + }, + ], + policies: [], + }); + expect(adapter.fakeServers[0].closed).toBe(true); + }); + it('reflects the effective model and its catalog default without pinning overrides', async () => { const adapter = new TestCodex(); const events: AgentEvent[] = []; @@ -118,6 +189,20 @@ describe('CodexAdapter shell-command passthrough', () => { expect(events).toContainEqual({ type: 'model-update', model: 'gpt-5.6-sol' }); expect(events).toContainEqual({ type: 'effort-update', effort: 'low' }); + expect(events).toContainEqual({ + type: 'available-models-update', + models: expect.arrayContaining([ + expect.objectContaining({ + id: 'gpt-5.6-sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + defaultEffort: 'low', + }), + expect.objectContaining({ + id: 'gpt-5.6-luna', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + }), + ]), + }); expect(adapter.fakeServers[0].requests).toContainEqual({ method: 'thread/start', params: expect.objectContaining({ model: undefined }), @@ -188,10 +273,180 @@ describe('CodexAdapter shell-command passthrough', () => { expect(events).toContainEqual({ type: 'effort-update', effort: 'high' }); }); + it.each([ + 'max', + 'ultra', + ] as const)('accepts Sol effort %s and sends it on the first turn', async (effort) => { + const adapter = new TestCodex(); + await adapter.start({ ...start, effort }); + await adapter.send({ type: 'prompt', content: [textBlock('hi')] }); + + const turn = adapter.fakeServers[0].requests.find((request) => request.method === 'turn/start'); + expect(turn?.params).toMatchObject({ effort }); + }); + + it.each([ + 'max', + 'ultra', + ] as const)('defers effort %s validation when optional model discovery is unavailable', async (effort) => { + const adapter = new TestCodex(); + adapter.rejectMethod = 'model/list'; + + await expect(adapter.start({ ...start, effort })).resolves.toBeUndefined(); + await adapter.send({ type: 'prompt', content: [textBlock('hi')] }); + + expect(adapter.fakeServers[0].requests.map((request) => request.method)).toContain( + 'thread/start', + ); + const turn = adapter.fakeServers[0].requests.find((request) => request.method === 'turn/start'); + expect(turn?.params).toMatchObject({ effort }); + }); + + it.each([ + 'max', + 'ultra', + ] as const)('defers effort %s validation when model discovery has no usable entries', async (effort) => { + const adapter = new TestCodex(); + adapter.emptyModelList = true; + + await expect(adapter.start({ ...start, effort })).resolves.toBeUndefined(); + expect(adapter.fakeServers[0].requests.map((request) => request.method)).toContain( + 'thread/start', + ); + }); + + it('accepts Luna max but rejects Luna ultra from provider metadata', async () => { + const accepted = new TestCodex(); + await expect( + accepted.start({ ...start, model: 'gpt-5.6-luna', effort: 'max' }), + ).resolves.toBeUndefined(); + + const rejected = new TestCodex(); + await expect( + rejected.start({ ...start, model: 'gpt-5.6-luna', effort: 'ultra' }), + ).rejects.toThrow("codex: effort 'ultra' is not supported by model 'gpt-5.6-luna'"); + expect(rejected.fakeServers[0].closed).toBe(true); + expect( + rejected.fakeServers[0].requests.some((request) => request.method === 'thread/start'), + ).toBe(false); + }); + + it('validates live effort and model switches against the selected Codex model', async () => { + const adapter = new TestCodex(); + await adapter.start(start); + + await adapter.send({ type: 'set-model', model: 'gpt-5.6-terra' }); + await expect(adapter.send({ type: 'set-effort', effort: 'ultra' })).resolves.toBeUndefined(); + await expect(adapter.send({ type: 'set-model', model: 'gpt-5.6-luna' })).rejects.toThrow( + "codex: effort 'ultra' is not supported by model 'gpt-5.6-luna'", + ); + + await adapter.send({ type: 'set-effort', effort: 'max' }); + await expect( + adapter.send({ type: 'set-model', model: 'gpt-5.6-luna' }), + ).resolves.toBeUndefined(); + await expect(adapter.send({ type: 'set-effort', effort: 'ultra' })).rejects.toThrow( + "codex: effort 'ultra' is not supported by model 'gpt-5.6-luna'", + ); + }); + + it('keeps live picks until confirmation, then reflects provider corrections', async () => { + const adapter = new TestCodex(); + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start(start); + await adapter.send({ type: 'set-model', model: 'gpt-5.6-terra' }); + await adapter.send({ type: 'set-effort', effort: 'high' }); + events.length = 0; + + const server = adapter.fakeServers[0]; + server.notify('thread/settings/updated', { + threadId: 'thread-1', + threadSettings: { model: 'gpt-5.6-sol', effort: 'low' }, + }); + expect(events).not.toContainEqual({ type: 'model-update', model: 'gpt-5.6-sol' }); + expect(events).not.toContainEqual({ type: 'effort-update', effort: 'low' }); + await adapter.send({ type: 'prompt', content: [textBlock('use my latest picks')] }); + + const turn = server.requests.find((request) => request.method === 'turn/start'); + expect(turn?.params).toMatchObject({ model: 'gpt-5.6-terra', effort: 'high' }); + + server.notify('thread/settings/updated', { + threadId: 'thread-1', + threadSettings: { model: 'gpt-5.6-terra', effort: 'high' }, + }); + server.notify('thread/settings/updated', { + threadId: 'thread-1', + threadSettings: { model: 'gpt-5.6-luna', effort: 'medium' }, + }); + expect(events).toContainEqual({ type: 'model-update', model: 'gpt-5.6-luna' }); + expect(events).toContainEqual({ type: 'effort-update', effort: 'medium' }); + }); + + it('keeps a pending model pick when recovery resumes the previously active model', async () => { + const adapter = new TestCodex(); + adapter.threadResponse = { + thread: { id: 'thread-1' }, + model: 'gpt-5.6-luna', + reasoningEffort: 'low', + }; + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start(start); + await adapter.send({ type: 'set-model', model: 'gpt-5.6-terra' }); + await adapter.send({ type: 'set-effort', effort: 'ultra' }); + expect(events).toContainEqual({ type: 'model-update', model: 'gpt-5.6-terra' }); + events.length = 0; + + adapter.fakeServers[0].exit(); + await adapter.send({ type: 'prompt', content: [textBlock('use my pending model')] }); + + expect(adapter.fakeServers).toHaveLength(2); + const resumed = adapter.fakeServers[1]; + expect(resumed.requests).toContainEqual({ + method: 'thread/resume', + params: expect.objectContaining({ + threadId: 'thread-1', + model: 'gpt-5.6-terra', + }), + }); + expect(events).not.toContainEqual({ type: 'model-update', model: 'gpt-5.6-luna' }); + const turn = resumed.requests.find((request) => request.method === 'turn/start'); + expect(turn?.params).toMatchObject({ model: 'gpt-5.6-terra', effort: 'ultra' }); + }); + + it('reflects corrections after thread/resume confirms a pending model', async () => { + const adapter = new TestCodex(); + adapter.threadResponse = { + thread: { id: 'thread-1' }, + model: 'gpt-5.6-luna', + reasoningEffort: 'medium', + }; + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start(start); + await adapter.send({ type: 'set-model', model: 'gpt-5.6-terra' }); + + adapter.fakeServers[0].exit(); + adapter.threadResponse = { + thread: { id: 'thread-1' }, + model: 'gpt-5.6-terra', + reasoningEffort: 'medium', + }; + await adapter.send({ type: 'prompt', content: [textBlock('recover')] }); + events.length = 0; + + adapter.fakeServers[1].notify('thread/settings/updated', { + threadId: 'thread-1', + threadSettings: { model: 'gpt-5.6-luna', effort: 'medium' }, + }); + expect(events).toContainEqual({ type: 'model-update', model: 'gpt-5.6-luna' }); + }); + it('rejects Claude-only effort levels before starting app-server', async () => { const adapter = new TestCodex(); - await expect(adapter.start({ ...start, effort: 'max' })).rejects.toThrow( - "codex: effort 'max' is not supported", + await expect(adapter.start({ ...start, effort: 'ultracode' })).rejects.toThrow( + "codex: effort 'ultracode' is not supported", ); expect(adapter.fakeServers).toHaveLength(0); }); diff --git a/packages/host/agent-adapter/src/base.ts b/packages/host/agent-adapter/src/base.ts index 53e79b2f7..b0a0a8ad8 100644 --- a/packages/host/agent-adapter/src/base.ts +++ b/packages/host/agent-adapter/src/base.ts @@ -321,8 +321,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { protected emitCommands(commands: AgentCommand[]): void { this.emit({ type: 'available-commands-update', commands }); } - /** Announce the session's model catalog (full-replace semantics — see schema). Only adapters - * with an install-dependent model set emit this; static-catalog agents never do. */ + /** Announce the session's model catalog (full-replace semantics — see schema). */ protected emitModels(models: AgentModelOption[]): void { this.emit({ type: 'available-models-update', models }); } diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index 04a081a67..bf6150afa 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -230,7 +230,7 @@ class AsyncMessageQueue implements AsyncIterable { * pinned at xhigh. `max` never comes through here — it can't travel flag-settings (see `onSetEffort`). */ function effortFlagSettings( - effort: Exclude, + effort: Exclude, ): Parameters[0] { if (effort === 'ultracode') return { ultracode: true }; return { ultracode: null, effortLevel: effort }; @@ -475,7 +475,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { private readonly reflectEffortHook: HookCallback = (input) => { if (input.effort?.level) { const parsed = EffortLevelSchema.safeParse(input.effort.level); - if (parsed.success) { + if (parsed.success && parsed.data !== 'ultra') { const ultracode = this.effort === 'ultracode' || this.settingsUltracode; this.emitEffort(ultracode && parsed.data === 'xhigh' ? 'ultracode' : parsed.data); } @@ -699,7 +699,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { // Catalog discovery is optional and may wait on CLI initialization indefinitely. Do not hold // session.start behind it; publish whenever the snapshot becomes available. void this.publishCommands(q); - if (this.effort !== undefined && this.effort !== 'max') { + if (this.effort !== undefined && this.effort !== 'max' && this.effort !== 'ultra') { try { await q.applyFlagSettings(effortFlagSettings(this.effort)); this.emitEffort(this.effort); @@ -855,6 +855,9 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { * transition into or out of `max` closes the process and lets the next prompt rebuild the * `Query`, resuming in place via the session id sniffed off the last SDK message. */ protected override async onSetEffort(effort: EffortLevel): Promise { + if (effort === 'ultra') { + throw new Error("claude-code: effort 'ultra' is not supported"); + } const previous = this.effort; // Re-picking the current level is a no-op — it must not restart a live `max` process. if (effort === previous) return; diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index aed40884e..1e38eafcd 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -6,6 +6,8 @@ import type { AgentHistoryReadOptions, AgentHistoryReadResult, AgentHistoryResumeOptions, + AgentModelOption, + AgentStartCatalog, ApprovalPolicy, ApprovalPolicyState, ContentBlock, @@ -21,6 +23,8 @@ import { EffortLevelSchema } from '@linkcode/schema'; import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { invariant, nullthrow } from 'foxts/guard'; +import { noop } from 'foxts/noop'; +import type { AgentStartCatalogOptions } from '../../adapter'; import { AUTH_FAILED_ERROR_CODE } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import { codexEnv, readAgentCredential } from '../../credential'; @@ -94,25 +98,45 @@ export function codexSkillCommands(response: unknown): CodexSkillCommand[] { return [...commands.values()].sort((a, b) => a.name.localeCompare(b.name)); } -interface CodexModelDefaults { +interface CodexModelCatalog { defaultModel: string | undefined; - efforts: Map; + models: AgentModelOption[]; } -/** Read model-specific defaults from `model/list` without pinning them as thread/turn overrides. - * The response is an external JSON-RPC boundary, so accept only the verified fields. */ -function codexModelDefaults(response: unknown): CodexModelDefaults { - const defaults: CodexModelDefaults = { defaultModel: undefined, efforts: new Map() }; - if (!isRecord(response) || !Array.isArray(response.data)) return defaults; +/** Normalize the app-server's model catalog. `supportedReasoningEfforts` is an array of + * `{reasoningEffort, description}` objects (not strings); unknown provider values such as + * `minimal` stay out of LinkCode's closed vocabulary, and Claude's `ultracode` never crosses into + * Codex even if a malformed server advertises it. */ +function codexModelCatalog(response: unknown): CodexModelCatalog { + const catalog: CodexModelCatalog = { defaultModel: undefined, models: [] }; + if (!isRecord(response) || !Array.isArray(response.data)) return catalog; for (const candidate of response.data) { if (!isRecord(candidate)) continue; const model = stringField(candidate, 'model') ?? stringField(candidate, 'id'); if (!model) continue; - const effort = EffortLevelSchema.safeParse(candidate.defaultReasoningEffort); - if (effort.success) defaults.efforts.set(model, effort.data); - if (candidate.isDefault === true) defaults.defaultModel = model; + const advertised = candidate.supportedReasoningEfforts; + let effortLevels: EffortLevel[] | undefined; + if (Array.isArray(advertised)) { + const supported = new Set(); + for (const option of advertised) { + if (!isRecord(option)) continue; + const effort = EffortLevelSchema.safeParse(stringField(option, 'reasoningEffort')); + if (effort.success && effort.data !== 'ultracode') supported.add(effort.data); + } + effortLevels = [...supported]; + } + const parsedDefault = EffortLevelSchema.safeParse(candidate.defaultReasoningEffort); + const defaultEffort = + parsedDefault.success && parsedDefault.data !== 'ultracode' ? parsedDefault.data : undefined; + catalog.models.push({ + id: model, + label: stringField(candidate, 'displayName') ?? model, + ...(effortLevels !== undefined && { effortLevels }), + ...(defaultEffort && { defaultEffort }), + }); + if (candidate.isDefault === true) catalog.defaultModel = model; } - return defaults; + return catalog; } /** The slice of `CodexAppServer` the adapter drives — narrow so a test fake can satisfy it @@ -273,9 +297,19 @@ export class CodexAdapter extends BaseAgentAdapter { private resumeFrom: string | undefined; /** Model/effort for the next `turn/start`; `turn/start` overrides stick for subsequent turns. */ private model: string | undefined; + /** Live selections awaiting a matching settings update. Kept separate from the sticky turn + * overrides so a later provider correction is reflected after the selection is confirmed. */ + private pendingModel: string | undefined; + /** Model last confirmed by thread/start or thread/settings/updated. */ + private activeModel: string | undefined; private effort: EffortLevel | undefined; - /** Provider defaults keyed by model, refreshed from `model/list` for effective-effort fallback. */ - private modelDefaultEfforts = new Map(); + private pendingEffort: EffortLevel | undefined; + /** Provider catalog refreshed from `model/list`, including per-model effort capabilities. */ + private modelOptions = new Map(); + private catalogDefaultModel: string | undefined; + /** Whether the current app-server answered `model/list`. When discovery is unavailable, Codex + * remains the authority instead of optional metadata blocking an otherwise valid session. */ + private modelCatalogAvailable = false; /** Active approval/sandbox tier; switches ride the next `turn/start` like model/effort. */ private policyId: CodexPolicyId = INITIAL_POLICY_ID; /** True once the user explicitly picked a tier this session; only then may a preset override @@ -299,11 +333,26 @@ export class CodexAdapter extends BaseAgentAdapter { protected async onStart(opts: StartOptions): Promise { this.model = opts.model ?? undefined; + if (this.resumeFrom !== undefined) this.pendingModel = this.model; // openThread reflects the app-server's effective model after thread/start accepts or corrects // the requested override; the request itself is not provider confirmation. await this.ensureThread(); } + override async startCatalog(opts: AgentStartCatalogOptions = {}): Promise { + const server = await this.startAppServer({ + env: codexEnv(readAgentCredential(opts.config)), + onNotification: noop, + onExit: noop, + }); + try { + const catalog = codexModelCatalog(await server.request('model/list', {})); + return { models: catalog.models, policies: [] }; + } finally { + server.close(); + } + } + override async resumeHistory( opts: AgentHistoryResumeOptions, startOpts: StartOptions, @@ -478,27 +527,56 @@ export class CodexAdapter extends BaseAgentAdapter { * subsequent turns). Codex has no way to alter the turn already in flight. */ protected override onSetModel(model: string): Promise { invariant(this.opts, 'codex: session not started'); + if (this.effort !== undefined) { + this.assertEffortSupported(this.effort, model, !this.modelCatalogAvailable); + } this.opts.model = model; this.model = model; + this.pendingModel = model; // Reflect the pick now; it applies from the next turn/start. this.emitModel(model); return Promise.resolve(); } - /** Effort switching, same next-turn channel as the model. Codex accepts low–xhigh; - * `max`/`ultracode` are claude-code concepts with no codex equivalent. */ + /** Effort switching, same next-turn channel as the model. The active model's catalog is the + * authority; `ultracode` is always rejected because it is a distinct Claude-only mode. */ protected override onSetEffort(effort: EffortLevel): Promise { - if (effort === 'max' || effort === 'ultracode') { - return Promise.reject( - new Error(`codex: effort '${effort}' is not supported (codex accepts low through xhigh)`), - ); - } + this.assertEffortSupported(effort, this.selectedModel(), !this.modelCatalogAvailable); this.effort = effort; + this.pendingEffort = effort; // Reflect the pick now; it applies from the next turn/start. this.emitEffort(effort); return Promise.resolve(); } + private selectedModel(): string | undefined { + return ( + this.model ?? this.activeModel ?? this.catalogDefaultModel ?? this.opts?.model ?? undefined + ); + } + + private assertEffortSupported( + effort: EffortLevel, + model: string | undefined, + allowUnknown = false, + ): void { + if (effort === 'ultracode') { + throw new Error("codex: effort 'ultracode' is not supported"); + } + const supported = model ? this.modelOptions.get(model)?.effortLevels : undefined; + if (supported?.includes(effort)) return; + if (supported === undefined && (allowUnknown || (effort !== 'max' && effort !== 'ultra'))) { + return; + } + invariant( + supported, + `codex: effort '${effort}' cannot be validated because${model ? ` model '${model}'` : ' the active model'} did not advertise effort capabilities`, + ); + throw new Error( + `codex: effort '${effort}' is not supported${model ? ` by model '${model}'` : ''}`, + ); + } + private approvalPolicyState(): ApprovalPolicyState { return { availablePolicies: [...APPROVAL_POLICIES], currentPolicyId: this.policyId }; } @@ -583,31 +661,42 @@ export class CodexAdapter extends BaseAgentAdapter { this.server = server; // A fresh process re-read the on-disk credentials — its auth state is unknown again. this.authFailed = false; - const modelDefaultsPromise = this.readModelDefaults(server); const resume = this.resumeFrom ?? undefined; this.resumeFrom = undefined; - const preset = POLICY_PRESETS[this.policyId]; - const params = { - cwd: opts.cwd, - model: this.model, - approvalPolicy: preset.approvalPolicy, - ...(this.sandboxOverrideAllowed() && { sandbox: preset.sandboxMode }), - ...(opts.additionalDirectories?.length && { - config: { 'sandbox_workspace_write.writable_roots': opts.additionalDirectories }, - }), - }; - // A fresh thread's rollout holds nothing yet: announcing its history id now would trigger the - // clients' transcript seed read, whose uptoSeq cut can swallow the first prompt. Hold the - // announcement until the first turn is running; a resumed thread's rollout is complete, so - // announce immediately. Set before the request: thread/started can outrun the response. - this.holdSessionRef = !resume; try { + const modelCatalog = await this.readModelCatalog(server); + if (modelCatalog && modelCatalog.models.length > 0) { + this.modelCatalogAvailable = true; + this.catalogDefaultModel = modelCatalog.defaultModel; + this.modelOptions = new Map(modelCatalog.models.map((model) => [model.id, model])); + this.emitModels(modelCatalog.models); + } else { + this.modelCatalogAvailable = false; + this.catalogDefaultModel = undefined; + this.modelOptions.clear(); + } + if (this.effort !== undefined && !resume) { + this.assertEffortSupported(this.effort, this.selectedModel(), !this.modelCatalogAvailable); + } + const preset = POLICY_PRESETS[this.policyId]; + const params = { + cwd: opts.cwd, + model: this.model, + approvalPolicy: preset.approvalPolicy, + ...(this.sandboxOverrideAllowed() && { sandbox: preset.sandboxMode }), + ...(opts.additionalDirectories?.length && { + config: { 'sandbox_workspace_write.writable_roots': opts.additionalDirectories }, + }), + }; + // A fresh thread's rollout holds nothing yet: announcing its history id now would trigger + // the clients' transcript seed read, whose uptoSeq cut can swallow the first prompt. Hold the + // announcement until the first turn is running; a resumed thread's rollout is complete, so + // announce immediately. Set before the request: thread/started can outrun the response. + this.holdSessionRef = !resume; const response = resume ? await server.request('thread/resume', { ...params, threadId: resume, excludeTurns: true }) : await server.request('thread/start', params); - const modelDefaults = await modelDefaultsPromise; - this.modelDefaultEfforts = modelDefaults.efforts; - this.reflectThreadSettings(response, modelDefaults.defaultModel); + this.reflectThreadSettings(response, modelCatalog?.defaultModel, resume !== undefined); const thread = isRecord(response) ? recordField(response, 'thread') : undefined; const threadId = thread ? stringField(thread, 'id') : undefined; this.threadId = threadId ?? null; @@ -627,31 +716,45 @@ export class CodexAdapter extends BaseAgentAdapter { } } - /** Best-effort provider defaults. Older detected app-server builds may lack `model/list`; the - * thread response can still reflect any explicit configured effort without this catalog. */ - private async readModelDefaults(server: CodexServerHandle): Promise { + /** Best-effort provider catalog. Older detected app-server builds may lack `model/list`; the + * thread response can still reflect an explicit configured effort without this metadata. */ + private async readModelCatalog( + server: CodexServerHandle, + ): Promise { try { - return codexModelDefaults(await server.request('model/list', {})); + return codexModelCatalog(await server.request('model/list', {})); } catch { - return { defaultModel: undefined, efforts: new Map() }; + return undefined; } } - /** Reflect the app-server's effective model and effort. A null effort means no override, so the - * selected model's catalog default is the actual value. An explicit user pick remains pending - * until `thread/settings/updated` confirms what the next turn accepted. */ - private reflectThreadSettings(response: unknown, fallbackModel?: string): void { + /** Reflect the app-server's effective model and effort. A resumed thread can report its + * previously active model, so keep any next-turn override that was selected before recovery. + * A null effort means no override, so the selected model's catalog default is the actual value. */ + private reflectThreadSettings(response: unknown, fallbackModel?: string, resumed = false): void { if (!isRecord(response)) return; const model = stringField(response, 'model') ?? fallbackModel; - if (model) this.emitModel(model); - if (this.effort !== undefined) return; + if (model) { + this.activeModel = model; + if (this.pendingModel === undefined) { + if (!resumed && this.model !== undefined) this.model = model; + this.emitModel(model); + } else { + this.emitModel(this.pendingModel); + if (model === this.pendingModel) this.pendingModel = undefined; + } + } + if (this.effort !== undefined) { + this.assertEffortSupported(this.effort, this.model ?? model, !this.modelCatalogAvailable); + } + if (this.pendingEffort !== undefined) return; const effort = EffortLevelSchema.safeParse(response.reasoningEffort); const effective = effort.success ? effort.data : model - ? this.modelDefaultEfforts.get(model) + ? this.modelOptions.get(model)?.defaultEffort : null; - if (effective) this.emitEffort(effective); + if (effective && effective !== 'ultracode') this.emitEffort(effective); } /** Best-effort full catalog refresh. `skills/changed` invalidates every cached provider path, so @@ -785,13 +888,31 @@ export class CodexAdapter extends BaseAgentAdapter { const settings = recordField(params, 'threadSettings'); if (!settings) break; const model = stringField(settings, 'model'); - if (model) this.emitModel(model); + const modelMatchesPending = this.pendingModel === undefined || model === this.pendingModel; + if (model) { + this.activeModel = model; + if (modelMatchesPending) { + this.emitModel(model); + if (model === this.pendingModel) this.pendingModel = undefined; + } + } + // A previous turn can report after the user has picked next-turn overrides. Keep learning + // the effective model above, but do not replace the UI's newer pending model or its effort. + if (!modelMatchesPending) break; const effort = EffortLevelSchema.safeParse(settings.effort); - const effective = effort.success - ? effort.data - : this.effort === undefined && model - ? this.modelDefaultEfforts.get(model) - : undefined; + if (this.pendingEffort !== undefined) { + if (effort.success && effort.data === this.pendingEffort) { + this.emitEffort(effort.data); + this.pendingEffort = undefined; + } + break; + } + const effective = + effort.success && effort.data !== 'ultracode' + ? effort.data + : model + ? this.modelOptions.get(model)?.defaultEffort + : undefined; if (effective) this.emitEffort(effective); break; } diff --git a/packages/host/engine/src/__tests__/engine-session-attach.test.ts b/packages/host/engine/src/__tests__/engine-session-attach.test.ts index 1791b43d4..ab3001bbf 100644 --- a/packages/host/engine/src/__tests__/engine-session-attach.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-attach.test.ts @@ -85,7 +85,14 @@ describe('engine session attach', () => { adapter.emit({ type: 'available-models-update', models: [{ id: 'stale/old', label: 'Old' }] }); adapter.emit({ type: 'available-models-update', - models: [{ id: 'openai/gpt-5-nano', label: 'GPT-5 Nano', description: 'OpenAI' }], + models: [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + defaultEffort: 'low', + }, + ], }); const mark = sent.length; @@ -95,7 +102,14 @@ describe('engine session attach', () => { expect(catalogs).toEqual([ { type: 'available-models-update', - models: [{ id: 'openai/gpt-5-nano', label: 'GPT-5 Nano', description: 'OpenAI' }], + models: [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + defaultEffort: 'low', + }, + ], }, ]); }); diff --git a/packages/presentation/ui/src/__tests__/agent-models.test.ts b/packages/presentation/ui/src/__tests__/agent-models.test.ts index 2aad4676d..d9913fc99 100644 --- a/packages/presentation/ui/src/__tests__/agent-models.test.ts +++ b/packages/presentation/ui/src/__tests__/agent-models.test.ts @@ -62,6 +62,24 @@ describe('groupModelsByProvider', () => { }); describe('effortOptionsForModel', () => { + it('uses per-model Codex capabilities instead of the conservative agent fallback', () => { + expect(effortOptionsForModel('codex', codex?.[0])?.map((option) => option.id)).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', + ]); + expect(effortOptionsForModel('codex', codex?.[2])?.map((option) => option.id)).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + ]); + }); + it('limits Pi effort choices to the selected dynamic model', () => { expect( effortOptionsForModel('pi', { diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 2232a6d91..4d631aefe 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -471,6 +471,34 @@ describe('NewSessionSurface', () => { ); }); + it('drops remembered Codex ultra when the fallback model switches to Luna', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /GPT-5.6-Sol/ })); + await user.click(await screen.findByRole('menuitem', { name: 'GPT-5.6-Sol' })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: 'GPT-5.6-Luna' })); + typeInComposer('use Luna'); + await pressInComposer('Enter'); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ model: 'gpt-5.6-luna' })); + expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('effort'); + }); + it('submits a remembered dynamic-provider model even without a draft catalog', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( diff --git a/packages/presentation/ui/src/shell/agent-efforts.ts b/packages/presentation/ui/src/shell/agent-efforts.ts index 39f021ece..f98881caf 100644 --- a/packages/presentation/ui/src/shell/agent-efforts.ts +++ b/packages/presentation/ui/src/shell/agent-efforts.ts @@ -15,6 +15,7 @@ export const EFFORT_OPTIONS_BY_ID: Readonly> = high: { id: 'high', label: 'High', shortLabel: 'H' }, xhigh: { id: 'xhigh', label: 'xHigh', shortLabel: 'xH' }, max: { id: 'max', label: 'Max', shortLabel: 'Max' }, + ultra: { id: 'ultra', label: 'Ultra', shortLabel: 'U' }, ultracode: { id: 'ultracode', label: 'Ultracode', shortLabel: 'UC' }, }; @@ -25,8 +26,9 @@ export const EFFORT_OPTIONS_BY_ID: Readonly> = * process and resumes in place (entering and leaving — the startup flag outranks flag-settings); * `ultracode` needs dynamic workflows enabled, else the pick is rejected and the selector keeps * the previous level. - * codex: exactly these four (`minimal` isn't in our EffortLevel vocabulary; `max`/`ultracode` are - * claude-only and rejected); switches apply from the next turn, not mid-turn. + * codex: these four are the conservative fallback when model metadata is unavailable. A Codex + * catalog replaces them with each model's advertised levels (including `max`/`ultra`); `minimal` + * isn't in our normalized vocabulary and `ultracode` remains Claude-only. */ export const AGENT_EFFORT_OPTIONS: Partial> = { 'claude-code': [ @@ -58,7 +60,6 @@ export function effortOptionsForModel( model: ModelOption | undefined, ): EffortOption[] | undefined { const options = kind ? AGENT_EFFORT_OPTIONS[kind] : undefined; - if (!options || model?.effortLevels === undefined) return options; - const supported = new Set(model.effortLevels); - return options.filter((option) => supported.has(option.id)); + if (model?.effortLevels === undefined) return options; + return model.effortLevels.map((effort) => EFFORT_OPTIONS_BY_ID[effort]); } diff --git a/packages/presentation/ui/src/shell/agent-models.ts b/packages/presentation/ui/src/shell/agent-models.ts index 418a3c680..d5f7524a8 100644 --- a/packages/presentation/ui/src/shell/agent-models.ts +++ b/packages/presentation/ui/src/shell/agent-models.ts @@ -62,6 +62,8 @@ export const AGENT_DEFAULT_MODELS: Readonly>> 'grok-build': 'grok-4.5', }; +const CODEX_BASE_EFFORTS = ['low', 'medium', 'high', 'xhigh'] satisfies EffortLevel[]; + /** * Curated model choices, keyed by adapter — only adapters with a *verified* live model switch get * an entry, and every id was confirmed by reading the served model back off a live stream (source @@ -86,12 +88,24 @@ export const AGENT_MODEL_OPTIONS: Partial> = { { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, ], codex: [ - { id: 'gpt-5.6-sol', label: 'GPT-5.6-Sol' }, - { id: 'gpt-5.6-terra', label: 'GPT-5.6-Terra' }, - { id: 'gpt-5.6-luna', label: 'GPT-5.6-Luna' }, - { id: 'gpt-5.5', label: 'GPT-5.5' }, - { id: 'gpt-5.4', label: 'GPT-5.4' }, - { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini' }, + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: [...CODEX_BASE_EFFORTS, 'max', 'ultra'], + }, + { + id: 'gpt-5.6-terra', + label: 'GPT-5.6-Terra', + effortLevels: [...CODEX_BASE_EFFORTS, 'max', 'ultra'], + }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6-Luna', + effortLevels: [...CODEX_BASE_EFFORTS, 'max'], + }, + { id: 'gpt-5.5', label: 'GPT-5.5', effortLevels: [...CODEX_BASE_EFFORTS] }, + { id: 'gpt-5.4', label: 'GPT-5.4', effortLevels: [...CODEX_BASE_EFFORTS] }, + { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini', effortLevels: [...CODEX_BASE_EFFORTS] }, ], // Grok Build headless: model is a spawn-time `-m` flag (verified 0.2.102: grok-4.5). 'grok-build': [{ id: 'grok-4.5', label: 'Grok 4.5' }], diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 7836a9aae..20204e3c0 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -35,7 +35,7 @@ import { useTranslations } from 'use-intl'; import { AGENT_LABELS } from '../chat/agent-icon'; import { cn } from '../lib/cn'; import { repositoryLabel } from '../repository-label'; -import { AGENT_DEFAULT_MODELS } from './agent-models'; +import { AGENT_DEFAULT_MODELS, AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, MentionItem } from './composer'; @@ -168,7 +168,7 @@ export function NewSessionSurface({ const effort = localEffort === undefined ? (preferredEfforts?.[provider] ?? null) : localEffort; const catalog = agentCatalogs?.[provider]; const dynamicModels = catalog && catalog.models.length > 0 ? catalog.models : null; - const modelOption = dynamicModels?.find((option) => option.id === displayedModel); + const modelOption = resolveModel(dynamicModels ?? AGENT_MODEL_OPTIONS[provider], displayedModel); const effortLevels = modelOption?.effortLevels; const constrainedEffort = effortLevels === undefined || effortLevels.includes(effort ?? 'low') ? effort : null;