feat(dsh-acp): offer a per-session model picker - #2339
Merged
Conversation
An IDE session over the dsh bridge had no way to change models: the bridge published only the mode option, so a client's model dropdown had nothing to render and every session ran whatever `agent-default-model` resolved at creation time. ACP 0.25 carries no model state of its own, so the picker has to BE a session config option. The bridge now publishes a `model` select alongside the mode, built from `ctx.llm`'s catalog and grouped by provider — the pair is the identity, because the same model id can be served by two routes. A pick is validated through `resolveCallConfig` (which also materializes adapter defaults such as `reasoningEffort`) and applied to a per-session `ModelSelectionRef` installed via `installModelSelection` on both the create and the resume path, so it routes the next turn and nothing earlier. It is session-scoped on purpose: unlike the apiproxy host, an IDE session never writes back the dsh default. Unlike the mode the model is never locked — swapping which model answers the next step leaves every logged turn valid — and a reopened session comes back on the provider/model its own turns were logged under rather than on whatever the default has moved to since. The catalog is advisory, so the model in force is offered even when no adapter advertised it; a picker whose current value is missing from its options renders blank, which is the same broken dropdown as having none. When nothing can name a current model the picker is withheld instead of naming one the session does not run. Model switches share the preset switch queue (`presetSwitch` -> `configSwitch`) because every publication replaces the whole option set: an unordered reply would drop the other picker or carry a stale one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two provider ids serving the same models is a real deployment, not a mistake: a second route to the same vendor, mounted under its own id so it can carry its own key. The picker listed the catalog per provider, so a user with such a route saw every model twice — same name, same vendor, nothing to choose between, and only the value prefix telling the rows apart. Publish one row per model id. The provider carrying the session's current model owns the shared ids, so the list stays on the route that actually answers, and the checked row keeps its provider even when that provider's listing failed. A provider left with no models of its own drops out of the picker; one that has models nobody else serves keeps its group. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An ACP agent publishes its modes as a `mode`-category config option, and they were rendered inside the model dropdown, under the model list and above the fast-mode switch. Two unrelated choices shared one button: the trigger named the model, so nothing on the composer said which mode was in force, and opening the model list to change the permission mode read as the wrong menu. Split the mode into its own trigger beside the model, built on the same dropdown, keyboard, and positioning code as the reasoning preset picker, so the composer now reads model, mode, reasoning. The trigger shows the mode name. Protocol behaviour is untouched: the same `session/set_config_option` call, the same lock semantics once a conversation has started, the same option ids and test hooks. An agent that publishes a mode and no models keeps a full picker — the mode trigger takes over the context-usage badge and the tooltip that the model trigger would have carried, so nothing is lost when the model button is not rendered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wgqqqqq
force-pushed
the
fix/dsh-acp-model-picker
branch
from
August 18, 2026 02:18
9192300 to
adf1d3c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A dsh ACP session cannot select a model. ACP 0.25 carries no model state of its own — there is no
session/set_model— so a client's model dropdown IS a session config option withcategory: "model". This bridge only ever published the mode option, so the dropdown had nothing to render and every session ran whateveragent-default-modelresolved at creation time, for its whole life.The rest of the stack was already ready:
session_options.rsflattens grouped select options on the Rust side, andModelSelector.tsxbuilds its list offacpOptions.availableModels. What was missing was the agent-side publication.cordis.ymleven documented the old behaviour as deliberate, so this is a feature addition rather than a regression repair.What changed
modelselect alongside the mode, built fromctx.llm's catalog and grouped by provider. The value is theprovider/modelpair, because the same model id can be served by two routes and a bare id would not say which one ran.llm.resolveCallConfig(which also materializes adapter defaults such asreasoningEffort) and applied to a per-sessionModelSelectionRef, installed viainstallModelSelectionon both the create and the resume path. Withref.current === undefinedthat hook is a pure passthrough, which is why an uninitialized ref left the agent pinned to its creation-time route.dsh-host-apiproxy, this never writes backagent-default-model, so an IDE session does not mutate the model you chose in dsh.session.requestHeader()), not on whatever the default has moved to since.currentValueis absent from its own options renders blank, which is the same broken dropdown as having no options at all. Conversely, when nothing can name a current model the picker is withheld rather than naming one the session does not actually run.presetSwitchis renamed toconfigSwitchand shared with model switches, because every publication replaces the whole option set: an unordered reply would drop the other picker or carry a stale one.One row per model
Two provider ids serving the same models is a real deployment, not a mistake: a second route to the same vendor, mounted under its own id so it can carry its own key. Listed per provider, that made every model appear twice — same name, same vendor, nothing to choose between, and only the
provider/modelprefix telling the rows apart.modelOptionsnow publishes one row per model id. The provider carrying the session's current model owns the shared ids, so the list stays on the route that actually answers, and the checked row keeps its provider even when that provider's listing failed. A provider left with no models of its own drops out of the picker; one that serves models nobody else does keeps its group. The unlisted-model fallback above is unchanged.The mode gets a picker of its own
With the model list added, the ACP dropdown held three unrelated things: the models, the agent's
modeoption (dsh's agent presets; claude-code and codex publish permission modes in the same category), and the fast-mode switch. One trigger, and it named the model — so nothing on the composer said which mode was in force, and opening the model list to change a permission mode read as the wrong menu.AcpModeSelector, built on the same dropdown, keyboard, and positioning code asReasoningPresetSelector, rendered beside the model trigger. The composer now reads model, mode, reasoning, and the mode trigger shows the mode name.session/set_config_optioncall, the same lock semantics once a conversation has started, the same option ids and test hooks.handleSelectAcpModedid not move.modelSelector.acpMode/acpModeLocked/acpModeFailedcover it.Verification
From
packages/dsh-acp/:npx vitest run— 7 files / 39 tests pass, includingtests/model-selection.spec.ts(9 cases: the grouped catalog; a model shared by two routes listed once under the route in force; a switch actually routing the next turn, asserted off the requests the mock adapter received; still switchable after the mode locks; the lockingconfig_option_updatestill carrying the picker; refusing an unknown pair, a separator-less value, and a boolean; a cold session reopening on its logged model; and no picker when nothing pins a model).npx tsc -p tsconfig.json --noEmitandnpx tsc -p tsconfig.build.json --noEmit— clean.npm run buildandnode scripts/prepare-dsh-profile.mjs— both succeed; the latter is the only CI job that touches this package.scripts/smoke.mjsgained--model provider/model, anddescribeOptionsnow flattens grouped selects, so the path can be driven by hand against a real installation.From the repository root:
pnpm --dir src/web-ui run test:run src/flow_chat/components/ModelSelectorAcpMode.test.tsx— 4 pass, including a new case asserting the model dropdown no longer contains any mode row while both triggers render.pnpm --dir src/web-ui run test:runoverModelSelectorExternal.test.tsx,ModelSelectorPortalLayer.test.ts,ReasoningPresetSelector.test.tsx,acpSessionConfig.test.ts— 27 pass.pnpm run lint:webandpnpm run theme:color-audit:all— clean.pnpm run type-check:web— clean apart from a pre-existingGitTrustReporterror from a stale localsrc/web-ui/src/generated/api/(gitignored, unrelated to this branch).pnpm run desktop:dev: three separate triggers on the composer, the duplicated provider gone from the model list, and the mode picker locking after the first turn.