feat(cli): report run-config divergence instead of blocking on it - #333
feat(cli): report run-config divergence instead of blocking on it#333zxch3n wants to merge 23 commits into
Conversation
A probe's `configOptions` only describe the model that was current when it ran: Codex publishes `fast-mode` only while that model has a fast speed tier, and rebuilds the effort list on every model switch. Dispatch already knew this for a SEMANTIC selection, but concrete ids that never pass through that resolver — an Agent Role's stored `runConfig`, `--config-option`, a frozen Operation replayed after recovery — were still checked against the probed snapshot, so a Role pinning a fast-capable model failed create with "Unknown ACP config option for the selected agent: fast-mode" whenever the agent's default model had no fast tier. Apply the same rule to the values themselves. `resolvePerModelConfigOptionSelection` exempts per-model ids from the snapshot check when the turn runs a model other than the probed one, still validating effort strictly wherever the agent published its per-model breakdown, and reports the rest as unverified rather than rejecting them. A turn that runs the probed model keeps the snapshot authoritative, so a genuinely missing control is still refused. Also record the fast toggle in `validatedConfigIds` on a model switch, which the effort branch already did. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The applier suppressed user-visible warnings for exactly the four controls users most need told about — model, reasoning effort, Fast, Plan — on Codex and Claude, because it keyed the notice off a REJECTION and those calls fail routinely when re-sent per turn. But a rejection is the wrong signal in both directions. Codex ACCEPTS `fast-mode` on a model with no fast speed tier and then omits the option from the state it publishes: nothing throws, the turn runs at normal speed, and the user is told nothing. Meanwhile a rejected value that was already effective changed nothing and was reported. Key the notice off divergence instead: after applying the turn's config, compare each requested selection against the state the agent itself publishes, and warn only where they disagree. An `on`/`off` select and a boolean toggle are the same choice, so neither shape alone is a divergence. Where the agent published no config options at all — or for a sensitive id, which the runtime state deliberately omits — the failed call remains the only signal, so it is still used there. Rejections keep going to debug diagnostics unchanged, and `agent_warning` notices are already deduplicated by message per session, so a stuck divergence costs one notice rather than one per turn. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A probe's `configOptions` describe the model that was current when it was captured — and that snapshot is rewritten by every session this machine creates, so it describes whichever model ran last. It was nonetheless the authority for rejecting a turn: an id it omitted was "Unknown ACP config option", a value outside the list it recorded was "Allowed values: …", and a model or mode it had not seen was "Unsupported". None of that is evidence about the model the turn actually selects. Snapshots no longer reject anything. What stays a local error is only what cannot be dispatched under any model: a value the option's own declared type cannot carry. Everything else is sent as requested and reconciled against the state the agent publishes, which is the only thing that knows. The per-model exemption machinery goes with it. It existed to carve out the cases where the snapshot would wrongly reject; with rejection gone there is nothing to exempt, so `validatedConfigIds`, the skip set, and `resolvePerModelConfigOptionSelection` are deleted rather than maintained. One thing still fails loudly, and it is a different statement: a missing wire BINDING. When neither the snapshot nor the agent's own convention says how to spell a control, there is no request to send and inventing an id would be a silent no-op — so a semantic `fastMode` for an agent we have no binding for reports that it cannot be encoded, not that it is unsupported. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… own mode Two defects, one visible only because of the other. The applier set the permission mode BEFORE the model. Claude rebuilds the available permission modes on every model switch and downgrades the current one to `default` when the new model does not support it, so a turn that asked for plan/read-only and also switched models ran with WIDER permissions than it requested. Model and ordinary options now go first and permission-bearing ones last, so the last word belongs to what was asked for. `applyPromptConfig` already runs before `prompt`, so the state read afterwards is still taken before the agent can act on it. That was invisible because a successful `session/set_mode` overwrote the agent's reported mode with the requested one in the runtime patch — the request echoed back as if it were the outcome. It now only FILLS a mode the agent's own state does not report, mirroring what the model branch already did. Without this, no mode divergence could ever be reported. The regression test fails when the ordering is reverted; the existing fixture that hid it now reports the mode it accepted, like a real agent. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… omits Two ways a guess became a promise, and a promise became invisible. `applyAgentRoleRunConfigDefaults` seeded a new Role from `provisional` capabilities — the built-in static tables, a hand-maintained copy of a catalog the agent fetches per account. A Role is a durable promise about how a Session runs, so seeding it from a guess persisted that guess as the commitment. It is how a Role came to promise Fast mode for an agent whose probe never published the option. Only `authoritative` capabilities seed now; the rest stay unset for the user to choose. The composer then deleted what it could not find. Its authoritative branch walked the selector catalog and dropped every key outside it — but that catalog comes from a snapshot of ONE model, so a Role pinning Fast on a fast-capable model had its own value removed from the composer meant to show what the Role will do, and removed again from the dispatch table. A stored key with no selector is now kept while no runtime table exists, and `filterAcpSessionConfigOptionValues` drops only values a selector it HAS rejects. A present runtime table still owns the whole key set: that one is the agent's live state, not a snapshot of some other model, so an omission there is an answer. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s last Rewrites the per-model invariant around what the change actually established: a capability snapshot is evidence about one model and never rejects a run config, the only loud failure left is a missing wire binding, and Roles are seeded from authoritative capabilities alone. Adds the ordering invariant — permission-bearing config last, a set_mode acknowledgement is not the state, divergence is reported rather than blocked, and an upgrade must never turn a Role that used to run into one that fails. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f9a5cd4a1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| validatedConfigIds.push(configId); | ||
| } | ||
| configOptionValues[configId] = selection.reasoningEffort; | ||
| configOptionValues[option?.id ?? ACP_REASONING_EFFORT_CONFIG_ID] = selection.reasoningEffort; |
There was a problem hiding this comment.
Use Claude's effort binding when the snapshot omits it
When a Claude capability snapshot was captured under a model that omits the thought-level option, option is undefined and this falls back to the Codex-specific reasoning_effort id, even though this module identifies Claude's id as effort. After switching to the requested model, the applier therefore sends an unknown option and the session runs with the agent's default effort instead of the requested value. Select the fallback binding by agentType, as is already done for fast mode, or fail for a missing binding.
Useful? React with 👍 / 👎.
| for (const [configId, value] of Object.entries(values ?? {})) { | ||
| if (!cataloged.has(configId)) { | ||
| filtered[configId] = value; | ||
| } |
There was a problem hiding this comment.
Drop config keys absent from the current selector schema
When cached preferences contain an option from a previous agent, model, or capability revision, this loop now copies that uncataloged value into the supposedly filtered result. The landing and child-session creation paths call this helper immediately before dispatch and persist the result as defaults, so stale settings continue to be sent and stored rather than being removed at the dispatch boundary; preserve only explicitly recognized per-model exceptions instead of every unknown key.
AGENTS.md reference: packages/components/src/lib/AGENTS.md:L52-L56
Useful? React with 👍 / 👎.
Three review findings, all in the same seam. The fallback wire id for reasoning effort was Codex's `reasoning_effort` for every agent. Claude spells it `effort`, so a snapshot captured without the option — exactly the case the fallback exists for — sent Claude an id it has never heard of, and the turn ran at the default effort. Both fast mode and effort now resolve through one per-agent binding table, and an agent with no known binding reports that the control cannot be encoded rather than borrowing another agent's spelling. Preserving stored config values outside the selector catalog was too broad: a removed or renamed option has no surface left to clear it, so every new Session would resend and re-warn about it forever. Only ids Lody knows name a per-model control keep that exemption — the case it was for, a Role pinning Fast on a fast-capable model the probe never ran. Anything else uncataloged is stale and goes. The Role form still drew run-config controls from `provisional` capabilities while seeding refused to fill them, so a boolean rendered Off and saved as unset: the Role promised a configuration nobody chose. Those controls now appear only for capabilities the agent itself reported, with the existing "open the agent once, then edit this role" message otherwise. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d9217976c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // it only FILLS a mode the agent's own state does not report — never | ||
| // overwrites one, which would report the request back as if it were the | ||
| // outcome and leave every mode divergence invisible. | ||
| if (confirmedLegacyModeId && runtimeConfigPatch.modeId === undefined) { |
There was a problem hiding this comment.
Wait for fresh mode state before reporting divergence
For built-in Codex sessions, AgentClient.setSessionMode uses legacy session/set_mode and does not update its cached configOptions from the response. If the adapter does not synchronously emit a config_option_update, runtimeConfigPatch.modeId here is therefore the pre-call mode rather than a post-apply result; changing modes then records a false agent_warning and persists the old mode even though the request succeeded. Treat the cached value as unknown unless a post-call state update was observed.
AGENTS.md reference: apps/cli/AGENTS.md:L306-L309
Useful? React with 👍 / 👎.
…requested The single exception to "report, never block". Everything else — model, effort, fast — still runs and warns, because the worst case is a slower or costlier turn. Running with MORE permission than was asked for is not that: by the time a notice is readable the agent may already have edited files, and a session sitting in `auto` that refuses a `plan` request keeps approving without a human. It fires only on a contradiction the agent itself establishes. `runtimeConfigPatch.modeId` is the agent's published state — it is filled from a `set_mode` acknowledgement only when the agent reports no mode of its own, in which case the two are equal and nothing fires. `isAcpPermissionWiderThanRequested` then requires BOTH modes to be ranked among the builtin ones and the effective one to be strictly wider. A snapshot, a stale cache, an unranked third-party mode, an unconfirmed request, and a NARROWER outcome all answer false. The ranks come from the semantics the repo already classifies (read-only/plan < ask < model-reviewed < accept-edits < skip-approval), not from mode names. The way out is explicit and scoped to one turn: `SessionTurnInputConfig.acceptWiderPermission` is informed acceptance carried by a single resend — never inherited, never a default — and it stands the stop down while still reporting the mismatch. The failure keeps its own reason (`permission_not_applied`) so the client can name both modes and offer that resend instead of showing a generic pre-prompt error. Ablation: removing the detection fails the escalation test; ignoring the acceptance flag fails the stand-down test. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The permission_not_applied notice told the user to resend to run with the agent Permission it has, but a plain resend carries no acceptWiderPermission flag and fails again. State what happened; the affordance that sets the flag is a follow-up. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f0f61a322
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const permissionEscalation = | ||
| requestedModeId !== undefined && | ||
| !config.acceptWiderPermission && | ||
| isAcpPermissionWiderThanRequested(requestedModeId, runtimeConfigPatch.modeId) | ||
| ? { requestedModeId, effectiveModeId: runtimeConfigPatch.modeId as string } |
There was a problem hiding this comment.
Treat Codex mode state as unknown until refreshed
When built-in Codex changes modes, AgentClient.setSessionMode uses the legacy session/set_mode path without updating its cached configOptions, so moving from agent-auto-review or agent-full-access to read-only can leave runtimeConfigPatch.modeId holding the pre-call wider mode. This comparison then reports a permission escalation and MessageHandler aborts before prompting even though the mode request succeeded. Fresh evidence beyond the earlier comment is that the added permissionEscalation path now upgrades the stale-state warning into a failed turn; compare only state observed after the request or treat the cached mode as unknown.
AGENTS.md reference: apps/cli/AGENTS.md:L322-L324
Useful? React with 👍 / 👎.
| const requestedModeId = | ||
| config.modeId ?? | ||
| (typeof configOptionEntryFor(modeConfigId) === 'string' | ||
| ? (configOptionEntryFor(modeConfigId) as string) | ||
| : undefined); |
There was a problem hiding this comment.
Check agent-owned permission selectors for widening
For Grok and other agents whose approval setting is an agent-owned _permission option such as configOptionValues.permission_mode, this derives the requested permission only from the legacy mode selector. If ask is rejected or dropped while the live value remains always-approve, the code records a warning but never produces permissionEscalation, so the prompt executes with automatic approval. Include agent-owned permission-option values in the requested/effective widening comparison.
AGENTS.md reference: AGENTS.md:L67-L73
Useful? React with 👍 / 👎.
| * another. Blocking a turn on a guess about an unknown mode would be the same | ||
| * mistake as blocking it on a stale snapshot. | ||
| */ | ||
| const ACP_PERMISSION_MODE_RANKS: Record<string, number> = { |
There was a problem hiding this comment.
Rank DeepSeek's workspace-write permission mode
The rank table omits the built-in DeepSeek workspace-write mode even though packages/shared/src/ai.ts uses it as DeepSeek's default permission mode. If a turn requests read-only but the agent reports workspace-write, findAcpPermissionModeRank returns undefined for the effective side and the widening guard allows the prompt to run with write access. Add every adapted built-in permission mode, including workspace-write, at its correct width.
AGENTS.md reference: apps/cli/AGENTS.md:L319-L324
Useful? React with 👍 / 👎.
… agent has The stop that PR #333 added had no way out from the UI: the notice said the turn was stopped and a plain resend would be stopped again, because it carries no acceptance. The failure notice now offers the decision the stop exists to ask for. The action replays THAT turn — the prompt, mode, model, config option values and Role frozen in its own `inputConfig`, never the composer's current selection, which would pair the old prompt with settings the user has since changed. `acceptWiderPermission` rides only this dispatch: it is written into the new turn's input config and into nothing that outlives it, so the next ordinary send asks for the original permission again. The mismatch is still reported on the turn that runs. Both permissions are named, in the notice and on the button. "Run anyway" would hide which one the turn is about to run with, and that is the only thing the user is being asked to decide. `useOneShotAction` closes the double-click window before the first await — `pending` drives the disabled attribute, but React has not re-rendered when the second click lands — and always clears, so a failed attempt leaves the action usable rather than dead on the one turn being recovered. A newer user message supersedes the offer entirely: replaying then would inject the old turn behind whatever was just sent. Ablation: removing the in-flight guard fails the double-click test, removing the supersede rule fails the newer-message test, and defaulting the flag on fails the ordinary-send test. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7475c1aeac
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| * wider permission than requested and chose to run anyway. Scoped to the turn | ||
| * that carries it — never inherited, never a default. | ||
| */ | ||
| acceptWiderPermission?: boolean; |
There was a problem hiding this comment.
Clear one-time permission acceptance from edited turns
When a successfully accepted retry is later edited and resent, session-chat-interface.tsx builds the replacement with { ...originalConfig }, and SessionEditAndResendService.buildReplacementInputConfig preserves it again. Consequently this flag remains true for the newly edited prompt, bypassing the wider-permission stop without a new informed acceptance even though this field is documented as scoped to one turn. Strip it from replacement configurations unless the current action explicitly supplies it.
Useful? React with 👍 / 👎.
| const accepted = await dispatchInputBlocks(target.inputBlocks, { | ||
| modeIdOverride: target.modeId ?? null, | ||
| modelIdOverride: target.modelId ?? null, | ||
| configOptionValuesOverride: target.configOptionValues ?? {}, |
There was a problem hiding this comment.
Preserve the failed turn's MCP selection on retry
If the user changes the composer’s MCP selection after this failure notice appears, the retry passes the frozen prompt and run config but not the failed turn’s mcpServerIds; enqueueInputBlocks therefore reads mcpSelection.selectedIds from the current composer. The old prompt can consequently rerun with newly exposed tools or without tools it originally selected. Include the failed input config’s explicit MCP selection in the retry target and override rather than rereading composer state.
AGENTS.md reference: AGENTS.md:L52-L55
Useful? React with 👍 / 👎.
| : target.agentRoleId === null | ||
| ? null | ||
| : undefined, | ||
| acceptWiderPermission: true, |
There was a problem hiding this comment.
Carry retry acceptance through durable dispatch
When the machine is offline, this action still durably writes the retry turn, but SessionDispatchWatcher.buildChatRequestFromHistoryEntry and buildCreateRequestFromHistoryEntry reconstruct acpSessionConfig without entry.inputConfig.acceptWiderPermission. After reconnect the daemon therefore sees no acceptance and stops the same turn again, leaving the advertised recovery action ineffective for the supported offline-send path. Copy the one-turn flag into both reconstructed requests.
AGENTS.md reference: packages/components/src/components/sessions/AGENTS.md:L762-L764
Useful? React with 👍 / 👎.
| const permissionEscalation = | ||
| requestedModeId !== undefined && | ||
| !config.acceptWiderPermission && | ||
| isAcpPermissionWiderThanRequested(requestedModeId, runtimeConfigPatch.modeId) |
There was a problem hiding this comment.
Do not classify Claude dontAsk as wider
Even with fresh post-apply state, a Claude session that remains in dontAsk after requesting default or auto is stopped here because ACP_PERMISSION_MODE_RANKS assigns dontAsk rank 4. The built-in mode definition says dontAsk denies operations that are not pre-approved, so it is not wider than modes that can ask a human or reviewer to approve them. Rank it as non-widening so this guard does not turn that narrower outcome into a permission escalation.
AGENTS.md reference: apps/cli/AGENTS.md:L320-L324
Useful? React with 👍 / 👎.
Four review findings, all cases where the stop or the replay was narrower than the thing it claims to protect. The live permission check only looked at the MODE. Grok's real permission control is a `category: '_permission'` config option (`permission_mode`, `ask`/`auto`/`always-approve`), so a turn asking for `ask` that the agent reported as `always-approve` produced a warning and ran — precisely the escalation the stop exists for. Permission is now recognised in all three shapes it arrives in — the legacy `set_mode` selector, a `mode` config option, and an explicit `_permission` one — each requested value is compared against the agent's reported value for THAT control, and all of them are applied after the model so a model switch cannot overwrite them. The rank table gains the values it was missing: Grok's `ask`, and DeepSeek Harness's `workspace-write`, without which `read-only → workspace-write` was silent too. "Run once with the agent's permission" replayed the stopped turn's prompt and run config but let the composer supply `mcpServerIds`, `taskToolsEnabled` and `issuePRMentions` — so changing an unsent MCP selection and then accepting would pair the old prompt with tool reach that turn never had, and an explicit `mcpServerIds: []` was lost entirely. All of them now come from the frozen `inputConfig`; `acceptWiderPermission` remains the only thing the new turn adds. Kimi publishes reasoning as `thinking`, which the per-agent binding table did not know: a snapshot-less encode reported "cannot be encoded" and a saved Kimi Role value was filtered out as an unknown key. Hiding the Role form's run-config controls for unreported capabilities stopped a guess being seeded, but left the Role saveable with nothing pinned — a Role IS its run config and pins the permission mode. `validateAgentRoleForm` now refuses that combination; an existing Role that already carries values stays editable while its agent is unreachable. Ablation: each fix has a test that fails when it is reverted — `_permission` recognition, the two rank additions, the Kimi binding, the frozen tool reach, and the Role save gate. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e507a471f2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| : target.agentRoleId === null | ||
| ? null | ||
| : undefined, | ||
| acceptWiderPermission: true, |
There was a problem hiding this comment.
Forward retry acceptance through dispatchInputBlocks
In the normal idle/online retry, this call enters the direct branch, but dispatchInputBlocks reconstructs the arguments to directDispatchInputBlocks with only the mode, model, config values, and Role; it drops acceptWiderPermission and all three frozen tool overrides. The button therefore creates another unaccepted turn that is stopped by the same permission guard, while its MCP/task/mention reach falls back to the current composer. Fresh evidence beyond the prior offline-reconstruction comment is that the values are lost before either history persistence or RPC dispatch, so the retry is broken even online; forward these fields through the direct and queue branches.
AGENTS.md reference: AGENTS.md:L52-L55
Useful? React with 👍 / 👎.
Two review findings, both places where a narrower path defeated the guard above it. The permission retry handed `acceptWiderPermission` and the frozen tool config to `dispatchInputBlocks`, which forwarded only mode/model/config/Role to the direct and queue hops. The offer is shown while the agent is idle, so the direct hop is the one it takes: the new turn reached the daemon with no acceptance and was stopped again, while its MCP, task-tool and issue-mention values came from the composer rather than from the turn being replayed. They now travel as one `TurnScopedOverrides` carrier that the inner send takes as a REQUIRED field, so a route that forgets it fails to compile rather than silently producing a turn the daemon will stop. Every route — direct, queue, guide — forwards it, `applyTurnScopedOverrides` is the single last hop before `buildSessionTurnInputConfig`, the queued config reads the built config instead of re-reading the composer, and the button asks for `forceDirect` so the decision the user just made is not parked behind a queue. The Role create gate keyed on the run config being empty, which the composer defeats: chat landing and the input area create a Role from what they currently show, and under `provisional` capabilities that is the static tables' own defaults resolved into a non-empty value nobody chose. Emptiness cannot tell that from a real selection, so the rule is now the creation itself — `isEditingExistingRole` comes from `editor.mode`, never inferred from the value. Editing an existing Role stays open offline. Ablation: neutering the carrier at a hop, dropping the frozen MCP selection at the last hop, and reverting the gate to the emptiness check each fail their test. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uild The flag was in the schemas but not in `normalizeSessionTurnInputConfig`, which rebuilds the turn config field by field — and every transport runs the config through it: direct RPC, `session/dispatch-turn`, steer, the Loro history readback, queue promotion. So the acceptance the client set was dropped before the daemon could read it, and the accepted turn was stopped again. Two more hand-written rebuilds had the same hole: the dispatch watcher's `acpSessionConfig` constructions (the doc-driven path, which is the one the desktop actually takes) and queue promotion's own `buildSessionTurnInputConfig` call. All four now copy ONLY an explicit `true`. `false` and absent are the same answer — not accepted — and neither may be written back as something a later turn could read as an acceptance. The queued config's strict schema admits the field too, or a queued replay would be rejected whole. `buildCliHistoryInputConfig` deliberately does not copy it: CLI and MCP turns never carry an acceptance, and it must not become inheritable there. Ablation: removing the normalizer branch fails the round trip; copying the value verbatim instead of gating on `true` fails it too. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 09d9fc9070
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (config.acceptWiderPermission) { | ||
| return undefined; |
There was a problem hiding this comment.
Bind permission acceptance to the mode the user approved
When a stopped turn is replayed after reconnect or another agent-state change, this boolean bypasses the comparison before reading the newly reported permission. A user who accepted “Run once with auto” can therefore have the retry run with always-approve or full access if the effective state changes; freezing the requested config does not freeze the agent's outcome. Carry the approved effective mode (or its rank ceiling) and suppress the stop only when the newly reported mode is no wider than what was disclosed.
AGENTS.md reference: apps/cli/AGENTS.md:L322-L326
Useful? React with 👍 / 👎.
Making the acceptance survive every rebuild also made it copyable, and edit-and-resend copies: it normalizes the original turn's config, spreads it, and mints a new `userTurnId` with a new prompt. The consent the user gave for ONE prompt would have ridden along to a different one, and that turn would sail past the stop that exists to ask — a permission bypass assembled out of a spread. `deriveTurnInputConfigForNewTurn` is now the only way to build a turn config from another turn's, and it drops the acceptance. Edit-and-resend and the history replay import (which also rewrites the prompt) go through it. Same-turn rewrites deliberately do not: marking a turn `processing`, tagging `_lodyDeliveryKind`, or retrying its transport is still the turn the user accepted, so those keep it. `use-task-comment-dispatch` builds a fresh config rather than deriving one, so it never carried it in the first place. Ablation: making the derivation spread the config unchanged fails the new test; removing the normalizer branch fails both it and the round trip, so the two halves cannot drift apart unnoticed. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e6c99b7dd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| ...(target.mcpServerIds !== undefined | ||
| ? { mcpServerIdsOverride: target.mcpServerIds as McpServerId[] } | ||
| : {}), |
There was a problem hiding this comment.
Treat an absent retry MCP selection as empty
When the stopped turn lacks mcpServerIds—notably for CLI-created turns, because buildCliHistoryInputConfig never records it—this conditional emits no override, so applyTurnScopedOverrides supplies the current composer's MCP selection even though the dispatch watcher originally interpreted the absent field as []. Retrying can therefore expose the old prompt to MCP servers that were unavailable to the original turn. Fresh evidence beyond the earlier retry comment is the newly added turn-scoped-overrides test that explicitly expects this composer fallback for an absent selection; freeze absence as an empty selection instead.
AGENTS.md reference: AGENTS.md:L52-L55
Useful? React with 👍 / 👎.
`acceptWiderPermission: true` suppressed EVERY escalation on the turn that
carried it, while the button that sets it only ever discloses one — the pair
from the last failure notice. Two ways that hands out consent nobody gave:
the agent may have moved further by the time the turn re-runs (`plan → auto`
accepted, `plan → always-approve` live), and a second permission control may
have widened alongside the one in the notice, which Grok can do because it
carries both a mode selector and an explicit `_permission` one.
The acceptance is now the triple it was always meant to be —
`{ controlId, requestedModeId, effectiveModeId }` — written from the notice's
own meta, so the daemon can tell the disclosed difference from one that
appeared afterwards. The applier skips only an exact match and CONTINUES
scanning the remaining permission selections; anything else stops the turn
again and produces its own accurate notice. Matching is on the whole triple,
never on the values alone, so consent cannot transfer between permission
dimensions that happen to share them. A malformed or partial acceptance reads
as none.
Ablation, all three now caught: suppressing everything when accepted, ending
the scan at the accepted item instead of continuing, and dropping `controlId`
from the match.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 92e039ad27
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| * the agent reported. Structured so the notice can name both instead of the | ||
| * client parsing them back out of `message`. | ||
| */ | ||
| permission: z.object({ requestedModeId: z.string(), effectiveModeId: z.string() }).optional(), |
There was a problem hiding this comment.
Preserve the permission control in failure metadata
When a permission_not_applied notice is read through the exported parseMessageContentArray or safeParseMessageContentArray path, this nested Zod object strips the undeclared controlId. findPermissionNotAppliedRetryTarget requires all three permission fields and therefore returns null, so the “Run once” recovery action disappears for parsed history and the stopped turn cannot be resumed from the UI. Reuse AcceptedWiderPermissionSchema here so the durable notice retains the complete acceptance tuple.
Useful? React with 👍 / 👎.
…trol id `ChatFailedMetaSchema` still declared its own two-field `permission` shape while everything else had moved to the triple, and Zod strips undeclared keys: `controlId` was removed on every history read. The client requires all three to build an acceptance, so a user saw the failure but no "run once" action — the way out of a stop the daemon had reported correctly was unreachable through the real read path. The meta and the acceptance are the same thing — the client reads one and writes the other back — so they now share `AcceptedWiderPermissionSchema` rather than keeping a second shape that can drift. A malformed or pre-triple `permission` degrades to absent instead of failing: rejecting it would make the whole history item unparseable rather than merely actionless, and an acceptance that cannot name its control would be a blanket one, so dropping it is the safe direction. Ablation: the old two-field shape fails both new tests; making the triple strict without the degrade fails the legacy-notice one. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a82d970f5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if ( | ||
| typeof selection.requested !== 'string' || | ||
| typeof effective !== 'string' || | ||
| !isAcpPermissionWiderThanRequested(selection.requested, effective) |
There was a problem hiding this comment.
Rank Grok interaction modes per control
When builtin Grok is asked for interaction_mode=ask but reports agent, this value-only comparison treats both modes as rank 1 and allows the prompt to run. However, Grok defines ask as answering without modifying the workspace and agent as permitting tools and file changes (packages/components/src/lib/grok-acp-selector-i18n.ts:12-29), so this is a real widening that bypasses AcpPermissionNotAppliedError; rank values in the context of controlId rather than sharing one global mapping across interaction and approval controls.
AGENTS.md reference: apps/cli/AGENTS.md:L327-L340
Useful? React with 👍 / 👎.
Matching each acceptance exactly stopped the bypass but made the way out unreachable when two controls widen at once, which real Grok does: it carries both a mode selector and an explicit `_permission` one. The first stop disclosed one, the retry carried that acceptance alone, the applier correctly stopped on the second — and the next retry replaced the first acceptance with the second, landing back on the first stop. The user alternates between two notices forever. `acceptWiderPermissions` is now the list it needed to be: strictly validated, deduplicated and bounded. A retry inherits the acceptances already on the turn it replays — re-validated from that turn's own frozen config, so nothing but a previously accepted exact triple accumulates — and appends the current notice's triple. This grants nothing extra: the applier still matches every entry exactly and keeps scanning, so a difference nobody was shown stops the turn as before. The clearing rules are unchanged and now cover the whole set: an ordinary send, edit-and-resend and any prompt-derived turn carry none of it; a same-turn transport retry keeps it. A legacy boolean or a bare object reads as no acceptance rather than a partial one. Ablation: a retry that keeps only the current disclosure fails the accumulation test; an applier that matches only the first accepted entry fails the full loop. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 74e063a885
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // A new turn id and a changed prompt: the one-time permission acceptance | ||
| // the user gave the ORIGINAL prompt does not travel with it. | ||
| const inputConfig: SessionTurnInputConfig = deriveTurnInputConfigForNewTurn( | ||
| message.inputConfig, |
There was a problem hiding this comment.
Clear permission acceptances in the edit service
When an accepted retry is edited, this helper omits acceptWiderPermissions, but SessionEditAndResendService.buildReplacementInputConfig subsequently spreads original.inputConfig before the replacement; because omission does not overwrite the original property, the old acceptance survives onto the changed prompt and bypasses the permission stop. Fresh evidence beyond the earlier comment is that the current UI-side fix is undone by the still-active service merge at apps/cli/src/session/session-edit-and-resend-service.ts:487-493; explicitly remove the field at that final replacement boundary.
Useful? React with 👍 / 👎.
| options.capabilityAuthority !== undefined && | ||
| options.capabilityAuthority !== 'authoritative' && | ||
| !options.isEditingExistingRole |
There was a problem hiding this comment.
Block offline edits that retarget an existing Role
When editing an existing Role while the newly selected agent has provisional or unavailable capabilities, isEditingExistingRole bypasses this validation even if the user changed the machine or agent config. Selecting that config clears the mode, model, and option values, while the capability controls remain hidden, so Save persists a retargeted Role with an empty or unverified run config rather than merely allowing an offline rename. Limit the exemption to edits that preserve the original target and run config, or prevent retargeting until authoritative capabilities are available.
AGENTS.md reference: AGENTS.md:L67-L73
Useful? React with 👍 / 👎.
… knew Inheritance kept every uncataloged key, so a removed or renamed option rode the Session lineage forever: a new MCP `session_create` inherits it from the opener's last turn, dispatches it, the agent rejects or warns, and that config becomes the next Session's inheritance source — with no surface anywhere to clear it. The components side was narrowed to exactly this rule in 6d92179; the CLI kept the hole, and a test even pinned it. Inheritance is not a request. Nobody asked for these values on this turn, so an id the catalog does not know gets no benefit of the doubt — `filterInheritedTurnConfigOptionValues` keeps it only when `isAcpPerModelConfigId` recognises it, because those are absent from a snapshot whenever the captured model lacked them, which says nothing about the model a new Session runs. A cataloged option whose value the type cannot carry is still dropped. Explicit requests are untouched: `--config-option` and a frozen Operation request are asked for, and a snapshot of one model still does not get to refuse them. A test now pins that half too, so the split cannot quietly collapse in either direction. With no capability at all the same rule applies. An explicit request is always available, so a missing snapshot must not become a licence to carry every historical key forward — that is how the accumulation starts. Ablation: keeping every uncataloged key fails the new test, dropping the per-model ones fails it too, and rejecting unknown ids on the explicit path fails three. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ablation on this branch's own additions: removing the wiring that merges `findUnverifiedTurnSelectors` into `applyAgentRunConfigSelection` broke no test, because the value it produces is read nowhere. It was computed at three levels — the shared resolver, the CLI merge, the returned object — and consumed at zero. That is the same dead apparatus this branch set out to fix in the first place, rebuilt. The runtime divergence comparison is the report. An offline classification of what "could not be confirmed" only made sense while the snapshot could reject; with rejection gone it tells nobody anything. Deleted end to end, along with the effort/fast confirmation bookkeeping that existed solely to feed it, the now-unused selector-id helper, and the tests that pinned the value rather than any behaviour. `modelReasoningEfforts` keeps its real consumer — the MCP create-options summary. Two dedupe implementations of the accepted-permission triple became one, exported from shared: the client assembling a retry and the schema validating it on the way in now agree by construction, and the schema's bound is counted in the same entries. Ablation also found `.max(8)` and the schema dedupe passing silently — no test noticed their removal. Both are security-relevant (an unbounded durable list from a client; a bound that duplicates could exhaust), so they earned coverage rather than deletion: a 9-entry list now reads as no acceptance, and a repeated disclosure does not consume the budget. Also dropped `EMPTY_TURN_SCOPED_OVERRIDES` (no consumers) and unexported `findAcpPermissionModeRank` (used only inside its own module). Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 768456b148
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| record.acceptWiderPermissions | ||
| ); | ||
| if (acceptWiderPermissions?.length) { | ||
| normalized.acceptWiderPermissions = acceptWiderPermissions; |
There was a problem hiding this comment.
Strip one-turn acceptance from operation continuations
When a permission-accepted turn invokes an asynchronous MCP session operation, preserving this field lets resolveInvokingTurnContext copy it into frozenContinuationConfig (apps/cli/src/mcp/lody-mcp-server.ts:2200-2206), after which writeCompletionTurn spreads it into a new system turn while replacing the prompt and turn id (apps/cli/src/orchestration/operation-coordinator.ts:1013-1017). The durable completion and any nested operation snapshots therefore carry acceptWiderPermissions for a prompt the user never approved; sanitize those operation-derived configs with deriveTurnInputConfigForNewTurn before freezing or writing them.
AGENTS.md reference: apps/cli/AGENTS.md:L402-L408
Useful? React with 👍 / 👎.
Targeted review of the acceptance chain found four places where it leaks
across turns. The client-side rule was right; the enforcement was in the wrong
place.
Edit-and-resend rebuilds the config in the DAEMON as
`{...original.inputConfig, ...replacement}`. The client drops the acceptance,
but a replacement that merely lacks the field does not overwrite a present one,
so the CLI added it straight back: accept for prompt A, edit to prompt B, and B
runs past the stop nobody accepted it for. The rebuild now goes through
`deriveTurnInputConfigForNewTurn`, so a correct client is no longer what makes
this safe. Editing a queued item and the Operation completion turn are the same
derivation and now drop it too — the latter never dispatched it, but a nested
Operation would freeze and copy that history forward.
The notice also never said which turn it belonged to; the client took the
nearest user entry above it. Another client's turn, or an edit-and-resend
rewriting history between the failure and the notice landing, leaves a
different prompt there — and if it makes the same permission selection, the
acceptance runs it. The notice now carries `userTurnId` and the client matches
by id, with adjacency kept only for notices written before the field.
`deriveTurnInputConfigForNewTurn`'s overrides can no longer re-add the field,
and the speculative-preparation schema no longer accepts it: a preparation is
not a dispatched turn, so no difference was ever disclosed for it.
Reviewed and found clean: `controlId` agrees between the notice and the applier
for both the legacy `set_mode` selector and a `category:'mode'` option, and the
rank table covers every builtin permission value at the pinned adapter SHAs.
Ablation: reverting either P0 fails its regression test.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7058c9e412
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (permissionEscalation) { | ||
| throw new AcpPermissionNotAppliedError( |
There was a problem hiding this comment.
Stop the active prompt before rejecting a steer
When Claude steering advertises configPolicy: 'apply', steerSessionLocked calls this code while the preceding prompt is still in flight (session-execution-service.ts:1245-1249). If the steer switches models, Claude can reset plan to the wider default; when restoring plan then diverges, this throw only causes the steer to be requeued by the catch at lines 1344-1355, without cancelling the already-running prompt. That prompt can therefore continue making tool calls under the widened mode before any failure notice appears, so the active run must be yielded/cancelled before applying the steer configuration or the configuration must not mutate the live session until handoff.
AGENTS.md reference: apps/cli/AGENTS.md:L334-L338
Useful? React with 👍 / 👎.
A capability snapshot describes ONE model — the one that was current when
`session/new` answered. Codex only lists `fast-mode` while that model has a
fast speed tier, and both agents rebuild the effort list per model, so nothing
in the snapshot answers "can Luna run Fast?" and ACP has no request that asks.
Every surface was therefore reading a per-model fact off a model-scoped
snapshot and getting the wrong answer for every other model.
So the adapters now attach `_meta.lody.modelCapabilities` to `session/new`:
`{ version: 1, models: { <id>: { effortValues?, fastMode? } } }`, built from
the same `Model`/`ModelInfo` objects the config options already come from. The
CLI records it beside the snapshot along with `measuredForModelId`, the model
the snapshot is actually about.
It is advisory in one direction: it may describe a control the snapshot never
carried, and it never grants permission, authorizes a value, or rejects one. A
model it does not name is unknown, not unsupported — so the catalog is read
whole or ignored whole, since half a catalog would answer "no fast mode" for
models the agent could not fit under the bound. Freshness-gated by a TTL and by
`sourceVersion`, because a declaration heard from one adapter build says nothing
about the next.
Two storage rules the tests pin, both easy to undo by accident: an ordinary
refresh that heard no declaration must not clear the stored one, and the
write-dedup key must include the declared content or the first declaration to
arrive with an otherwise unchanged snapshot is silently dropped.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion The producer half of `_meta.lody.modelCapabilities` lives in the adapter repositories, so the consumer landed first and read an absent declaration as "nothing to say". These two pointers are what actually make it speak. Both are on `feat/declare-model-capabilities` in their own repositories; the pointers move again when those merge. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ca54a38939
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Content, not freshness: `receivedAt` is deliberately excluded so a | ||
| // re-probe that learns nothing new does not rewrite the row, while a | ||
| // catalog that actually changed does. |
There was a problem hiding this comment.
Refresh identical declarations before their TTL expires
When later probes return the same _meta.lody.modelCapabilities, they supply a fresh receivedAt, but omitting that field from this fingerprint makes the equality check at lines 3280-3284 return without persisting it. After 24 hours, readDeclaredModelCapabilities therefore permanently treats the declaration as stale until its content changes, causing model-specific Fast and effort capabilities to disappear even though the agent has repeatedly reconfirmed them; refresh the timestamp whenever an explicit declaration is received while retaining the no-write behavior for probes that omit it.
AGENTS.md reference: apps/cli/AGENTS.md:L304-L310
Useful? React with 👍 / 👎.
The declaration commit had been written on top of `fix: hide unknown context window usage`, which is what the submodule happened to be checked out at and is not on the adapter's `main`. Rebased onto `main` so the PR carries one independent commit, and the pointer follows. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stacked on #324 (which is stacked on #316). Review those first; the base flips as they merge.
This is step 1 of the ACP run-config plan: no capability evidence blocks a turn any more. It needs no new data and, on its own, removes the reported failure.
Why the old rule was wrong
A capability probe starts the agent, sends one
session/new, and stores the returnedconfigOptions. That array describes the model that was current at that moment — andscheduleCreatedSessionCapabilityUpdaterewrites the whole row on every session this machine creates, so in practice it describes whichever model ran last. It was nonetheless the authority for rejecting a turn: an id it omitted wasUnknown ACP config option, a value outside its list wasAllowed values: …, an unseen model or mode wasUnsupported.None of that is evidence about the model the turn selects. ACP has no way to express per-model availability — an option is simply absent when the current model lacks it — and no way to ask what another model would offer.
What changed
Snapshots report; they do not reject.
validateTurnConfigOptionValuesnow rejects only what no model could carry: a value the option's own declared TYPE forbids (a boolean handed a string, a select handed a boolean). Unknown ids, out-of-list values and unseen model/mode ids are dispatched and reconciled against the state the agent publishes.findUnverifiedTurnSelectorsandunverifiedSelectionsrecord what could not be confirmed; neither blocks.The per-model exemption machinery is deleted, not maintained.
validatedConfigIds, the skip set andresolvePerModelConfigOptionSelection(from #316) existed only to carve out the cases where the snapshot would wrongly reject. With rejection gone there is nothing to exempt. A published per-model breakdown still CONFIRMS a value — that is what keeps it out ofunverifiedSelections— it just never rejects one.A missing wire binding still fails loudly, and says so. When neither the snapshot nor the agent's own convention says how to spell a control, there is no request to send and inventing an id would be a silent no-op. A semantic
fastModefor an agent we have no binding for now reports that it cannot be encoded, not that it is unsupported.Two defects found while doing it
Permission-bearing config was applied first and silently widened. The applier set the mode before the model; Claude rebuilds its permission modes on a model switch and downgrades the current one to
defaultwhen the new model lacks it. A turn asking for plan/read-only and switching models ran with wider permissions than requested. Model and ordinary options now go first, permission-bearing ones last.applyPromptConfigalready runs beforeprompt, so the state read afterwards is still taken before the agent acts.The runtime patch echoed the request back as the outcome. A successful
session/set_modeoverwrote the agent's reported mode with the requested one, so no mode divergence could ever be reported — and it is why the ordering bug was invisible. It now only FILLS a mode the agent's own state does not report, mirroring what the model branch already did. The regression test fails when the ordering is reverted; the fixture that hid it now reports what it accepted, like a real agent.Client side
A guess could become a durable promise.
applyAgentRoleRunConfigDefaultsseeded new Roles fromprovisionalcapabilities — the built-in static tables, a hand-maintained copy of a catalog the agent fetches per account. That is how a Role came to promise Fast mode for an agent whose probe never published the option. Onlyauthoritativecapabilities seed now.And the promise then became invisible. The composer's authoritative branch dropped every key outside its selector catalog, so a Role pinning Fast on a fast-capable model had its own value removed from the composer meant to show what the Role will do — and removed again from the dispatch table. Stored keys with no selector are kept while no runtime table exists; a present runtime table (the agent's live state, not another model's snapshot) still owns the whole key set.
Compatibility
Strictly loosening for existing data: stale caches, legacy entries and Roles carrying values the current snapshot does not list all stop being rejected. Roles and frozen Operations behave exactly like ordinary preferences at run time — an upgrade must never turn a Role that used to run into one that fails, and that is now written into
apps/cli/AGENTS.mdalong with both new invariants.pnpm typecheck,pnpm lint,check:public-boundary, and the shared (1018) / CLI (2468) / components (3018) suites all pass.Not in this PR
Steps 2 and 3 — having the two built-in adapters declare their per-model capabilities through ACP
_meta, then deleting the hardcodedmax/ultrainjection once telemetry shows coverage. Those need adapter submodule releases and a coverage gate.🤖 Generated with Claude Code