Skip to content

Commit 9d4db79

Browse files
authored
Merge pull request #2339 from wgqqqqq/fix/dsh-acp-model-picker
feat(dsh-acp): offer a per-session model picker
2 parents 729562e + adf1d3c commit 9d4db79

14 files changed

Lines changed: 1201 additions & 131 deletions

File tree

packages/dsh-acp/README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ the harness's own `dsh-settings-file`, `dsh-credentials-local`, and
2828
`dsh-agent-default-model` services, so switching models in dsh switches them in
2929
BitFun too. BitFun writes no DeepSeek credentials of its own.
3030

31+
A session also opens with a model picker of its own: the bridge publishes every
32+
model your dsh providers advertise as the `model` session config option,
33+
grouped by provider, starting on the default above. Picking one applies from
34+
the next message and lasts that session — it does not rewrite your dsh default.
35+
3136
## How BitFun launches it
3237

3338
BitFun runs `dsh --profile bitfun-acp`. A dsh profile is just a directory under
@@ -71,15 +76,19 @@ reopened conversation loses its history, its context, and the mode it ran under
7176

7277
`session/load` resumes the stored session out of the harness's own persistence
7378
(`$DSH_HOME/acp-sessions/<project>/<session-id>/`), replays its events to the
74-
client as `session/update` notifications, and answers with the session's mode.
75-
Three consequences worth knowing:
79+
client as `session/update` notifications, and answers with the session's mode
80+
and model. Four consequences worth knowing:
7681

7782
- **The stored mode wins over the roster default.** Which preset a session ran
7883
under is read back from its own log, so a conversation started in `minimal`
7984
reopens in `minimal` however the default has moved since.
8085
- **A conversation that has started comes back locked.** The mode picker shrinks
8186
to the one mode in force, because the composition is already baked into the
8287
transcript — the same rule a live session follows after its first turn.
88+
- **The model comes back off the log too, and stays switchable.** The picker
89+
opens on the provider/model the session's own turns were logged under, not on
90+
whatever the dsh default has become. Unlike the mode it is never locked:
91+
swapping which model answers the next step leaves every logged turn valid.
8392
- **A session belongs to the directory it was created in.** Loading it against
8493
another `cwd` is refused rather than answered with a session whose sandbox
8594
boundary points somewhere else.

packages/dsh-acp/cordis.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
# the harness the user already installed and configured: `$DSH_HOME/settings.yaml`
2020
# (what dsh's web Models page writes) and `$DSH_HOME/.credentials.yaml`, read
2121
# through the two provider rows below. Nothing about an account is stored in
22-
# this repository, and BitFun never asks for a key of its own.
22+
# this repository, and BitFun never asks for a key of its own. The bridge offers
23+
# those same routes to the client as the `model` session config option, so an
24+
# IDE picks per session out of the catalog dsh already holds.
2325

2426
# User-settings document ($DSH_HOME/settings.yaml, hot-reloaded): its
2527
# `llm-deepseek:` / `llm-pi-ai:` sections override the adapter rows below
@@ -104,9 +106,12 @@
104106
# keeps its registries (tools, skills, goals, jobs, agents, loop) and hands the
105107
# model-facing plugins to ./presets. `persona` is likewise absent — each preset
106108
# carries its own through `@deepseek-ai/dsh-persona`.
107-
# `provider`/`model` are deliberately absent: each session starts on
109+
# `provider`/`model` are deliberately absent: each session STARTS on
108110
# `agent-default-model`'s live selection, so the model chosen in dsh is the
109-
# model an IDE session runs. Sessions live under the harness home rather than
111+
# model an IDE session opens on. From there the client picks per session
112+
# through the `model` config option, over the catalog the rows above register;
113+
# that pick lasts the session and is not written back as the dsh default.
114+
# Sessions live under the harness home rather than
110115
# beside whatever project happens to be open — an ACP client launches this
111116
# adapter with the USER's workspace as the working directory.
112117
- id: acp-agent

packages/dsh-acp/scripts/smoke.mjs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
* a directory name under `$DSH_HOME/acp-sessions/<project>/` — and `--cwd` must
2828
* name the workspace it was created in.
2929
*
30-
* Usage: `node scripts/smoke.mjs [--mode code] [--load <id>] [--prompt "…"] [--reject] [--cancel-after 3000]`
30+
* Usage: `node scripts/smoke.mjs [--mode code] [--model deepseek-official/deepseek-v4]
31+
* [--load <id>] [--prompt "…"] [--reject] [--cancel-after 3000]`
3132
*/
3233

3334
import { spawn } from 'node:child_process'
@@ -45,6 +46,7 @@ const { values } = parseArgs({
4546
prompt: { type: 'string' },
4647
load: { type: 'string' },
4748
mode: { type: 'string' },
49+
model: { type: 'string' },
4850
profile: { type: 'string' },
4951
cwd: { type: 'string' },
5052
reject: { type: 'boolean' },
@@ -99,8 +101,13 @@ function describe(update) {
99101
function describeOptions(configOptions) {
100102
if (configOptions === undefined || configOptions.length === 0) return '(none)'
101103
return configOptions.map(option => {
104+
// A select's values are either flat or grouped by provider; a picker
105+
// renders both as one list, so this flattens the grouped form too.
102106
const values = option.type === 'select'
103-
? option.options.map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value)).join(' ')
107+
? option.options
108+
.flatMap(entry => (entry.options === undefined ? [entry] : entry.options))
109+
.map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value))
110+
.join(' ')
104111
: String(option.currentValue)
105112
return `${option.id}(${option.category ?? '-'}): ${values}`
106113
}).join(' | ')
@@ -133,7 +140,7 @@ try {
133140
: { sessionId: values.load, ...await client.loadSession({ sessionId: values.load, cwd: WORKSPACE, mcpServers: [] }) }
134141
const sessionId = session.sessionId
135142
process.stdout.write(`${values.load === undefined ? 'newSession' : 'loadSession'}: ${sessionId}\n`)
136-
process.stdout.write(`modes: ${describeOptions(session.configOptions)}\n`)
143+
process.stdout.write(`options: ${describeOptions(session.configOptions)}\n`)
137144

138145
if (values.mode !== undefined) {
139146
const switched = await client.setSessionConfigOption({
@@ -144,6 +151,17 @@ try {
144151
process.stdout.write(`mode ${values.mode}: ${describeOptions(switched.configOptions)}\n`)
145152
}
146153

154+
// `--model provider/model` is the composer's model dropdown: unlike the mode
155+
// it stays live for the whole session, so this can follow a prompt too.
156+
if (values.model !== undefined) {
157+
const switched = await client.setSessionConfigOption({
158+
sessionId,
159+
configId: 'model',
160+
value: values.model,
161+
})
162+
process.stdout.write(`model ${values.model}: ${describeOptions(switched.configOptions)}\n`)
163+
}
164+
147165
if (values.prompt !== undefined) {
148166
const pending = client.prompt({ sessionId, prompt: [{ type: 'text', text: values.prompt }] })
149167
if (values['cancel-after'] !== undefined) {

0 commit comments

Comments
 (0)