Skip to content

feat(dsh-acp): offer a per-session model picker - #2339

Merged
wgqqqqq merged 3 commits into
GCWing:mainfrom
wgqqqqq:fix/dsh-acp-model-picker
Aug 18, 2026
Merged

feat(dsh-acp): offer a per-session model picker#2339
wgqqqqq merged 3 commits into
GCWing:mainfrom
wgqqqqq:fix/dsh-acp-model-picker

Conversation

@wgqqqqq

@wgqqqqq wgqqqqq commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

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 with category: "model". This bridge only ever published the mode option, so the dropdown had nothing to render and every session ran whatever agent-default-model resolved at creation time, for its whole life.

The rest of the stack was already ready: session_options.rs flattens grouped select options on the Rust side, and ModelSelector.tsx builds its list off acpOptions.availableModels. What was missing was the agent-side publication. cordis.yml even documented the old behaviour as deliberate, so this is a feature addition rather than a regression repair.

What changed

  • The bridge publishes a model select alongside the mode, built from ctx.llm's catalog and grouped by provider. The value is the provider/model pair, because the same model id can be served by two routes and a bare id would not say which one ran.
  • A pick is validated through llm.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. With ref.current === undefined that hook is a pure passthrough, which is why an uninitialized ref left the agent pinned to its creation-time route.
  • The pick is session-scoped on purpose: unlike dsh-host-apiproxy, this never writes back agent-default-model, so an IDE session does not mutate the model you chose in dsh.
  • Unlike the mode, the model is never locked — swapping which model answers the next step leaves every logged turn valid. A reopened session comes back on the provider/model its own turns were logged under (session.requestHeader()), not on whatever the default has moved to since.
  • The catalog is advisory: an adapter may accept ids it never advertised, so the model in force is offered even when unlisted. A picker whose currentValue is 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.
  • presetSwitch is renamed to configSwitch and 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/model prefix telling the rows apart.

modelOptions now 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 mode option (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.

  • New AcpModeSelector, built on the same dropdown, keyboard, and positioning code as ReasoningPresetSelector, rendered beside the model trigger. The composer now reads model, mode, reasoning, and the mode 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. handleSelectAcpMode did not move.
  • 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 the model trigger would have carried, so nothing is lost when there is no model button to render.
  • No new i18n keys — the existing modelSelector.acpMode / acpModeLocked / acpModeFailed cover it.

Verification

From packages/dsh-acp/:

  • npx vitest run — 7 files / 39 tests pass, including tests/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 locking config_option_update still 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 --noEmit and npx tsc -p tsconfig.build.json --noEmit — clean.
  • npm run build and node scripts/prepare-dsh-profile.mjs — both succeed; the latter is the only CI job that touches this package.
  • scripts/smoke.mjs gained --model provider/model, and describeOptions now 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:run over ModelSelectorExternal.test.tsx, ModelSelectorPortalLayer.test.ts, ReasoningPresetSelector.test.tsx, acpSessionConfig.test.ts — 27 pass.
  • pnpm run lint:web and pnpm run theme:color-audit:all — clean.
  • pnpm run type-check:web — clean apart from a pre-existing GitTrustReport error from a stale local src/web-ui/src/generated/api/ (gitignored, unrelated to this branch).
  • Manually against a dsh ACP session in 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.

wgqqqqq and others added 3 commits August 17, 2026 20:11
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
wgqqqqq force-pushed the fix/dsh-acp-model-picker branch from 9192300 to adf1d3c Compare August 18, 2026 02:18
@wgqqqqq
wgqqqqq merged commit 9d4db79 into GCWing:main Aug 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant