Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/dsh-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ the harness's own `dsh-settings-file`, `dsh-credentials-local`, and
`dsh-agent-default-model` services, so switching models in dsh switches them in
BitFun too. BitFun writes no DeepSeek credentials of its own.

A session also opens with a model picker of its own: the bridge publishes every
model your dsh providers advertise as the `model` session config option,
grouped by provider, starting on the default above. Picking one applies from
the next message and lasts that session — it does not rewrite your dsh default.

## How BitFun launches it

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

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

- **The stored mode wins over the roster default.** Which preset a session ran
under is read back from its own log, so a conversation started in `minimal`
reopens in `minimal` however the default has moved since.
- **A conversation that has started comes back locked.** The mode picker shrinks
to the one mode in force, because the composition is already baked into the
transcript — the same rule a live session follows after its first turn.
- **The model comes back off the log too, and stays switchable.** The picker
opens on the provider/model the session's own turns were logged under, not on
whatever the dsh default has become. Unlike the mode it is never locked:
swapping which model answers the next step leaves every logged turn valid.
- **A session belongs to the directory it was created in.** Loading it against
another `cwd` is refused rather than answered with a session whose sandbox
boundary points somewhere else.
Expand Down
11 changes: 8 additions & 3 deletions packages/dsh-acp/cordis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
# the harness the user already installed and configured: `$DSH_HOME/settings.yaml`
# (what dsh's web Models page writes) and `$DSH_HOME/.credentials.yaml`, read
# through the two provider rows below. Nothing about an account is stored in
# this repository, and BitFun never asks for a key of its own.
# this repository, and BitFun never asks for a key of its own. The bridge offers
# those same routes to the client as the `model` session config option, so an
# IDE picks per session out of the catalog dsh already holds.

# User-settings document ($DSH_HOME/settings.yaml, hot-reloaded): its
# `llm-deepseek:` / `llm-pi-ai:` sections override the adapter rows below
Expand Down Expand Up @@ -104,9 +106,12 @@
# keeps its registries (tools, skills, goals, jobs, agents, loop) and hands the
# model-facing plugins to ./presets. `persona` is likewise absent — each preset
# carries its own through `@deepseek-ai/dsh-persona`.
# `provider`/`model` are deliberately absent: each session starts on
# `provider`/`model` are deliberately absent: each session STARTS on
# `agent-default-model`'s live selection, so the model chosen in dsh is the
# model an IDE session runs. Sessions live under the harness home rather than
# model an IDE session opens on. From there the client picks per session
# through the `model` config option, over the catalog the rows above register;
# that pick lasts the session and is not written back as the dsh default.
# Sessions live under the harness home rather than
# beside whatever project happens to be open — an ACP client launches this
# adapter with the USER's workspace as the working directory.
- id: acp-agent
Expand Down
24 changes: 21 additions & 3 deletions packages/dsh-acp/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
* a directory name under `$DSH_HOME/acp-sessions/<project>/` — and `--cwd` must
* name the workspace it was created in.
*
* Usage: `node scripts/smoke.mjs [--mode code] [--load <id>] [--prompt "…"] [--reject] [--cancel-after 3000]`
* Usage: `node scripts/smoke.mjs [--mode code] [--model deepseek-official/deepseek-v4]
* [--load <id>] [--prompt "…"] [--reject] [--cancel-after 3000]`
*/

import { spawn } from 'node:child_process'
Expand All @@ -45,6 +46,7 @@ const { values } = parseArgs({
prompt: { type: 'string' },
load: { type: 'string' },
mode: { type: 'string' },
model: { type: 'string' },
profile: { type: 'string' },
cwd: { type: 'string' },
reject: { type: 'boolean' },
Expand Down Expand Up @@ -99,8 +101,13 @@ function describe(update) {
function describeOptions(configOptions) {
if (configOptions === undefined || configOptions.length === 0) return '(none)'
return configOptions.map(option => {
// A select's values are either flat or grouped by provider; a picker
// renders both as one list, so this flattens the grouped form too.
const values = option.type === 'select'
? option.options.map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value)).join(' ')
? option.options
.flatMap(entry => (entry.options === undefined ? [entry] : entry.options))
.map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value))
.join(' ')
: String(option.currentValue)
return `${option.id}(${option.category ?? '-'}): ${values}`
}).join(' | ')
Expand Down Expand Up @@ -133,7 +140,7 @@ try {
: { sessionId: values.load, ...await client.loadSession({ sessionId: values.load, cwd: WORKSPACE, mcpServers: [] }) }
const sessionId = session.sessionId
process.stdout.write(`${values.load === undefined ? 'newSession' : 'loadSession'}: ${sessionId}\n`)
process.stdout.write(`modes: ${describeOptions(session.configOptions)}\n`)
process.stdout.write(`options: ${describeOptions(session.configOptions)}\n`)

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

// `--model provider/model` is the composer's model dropdown: unlike the mode
// it stays live for the whole session, so this can follow a prompt too.
if (values.model !== undefined) {
const switched = await client.setSessionConfigOption({
sessionId,
configId: 'model',
value: values.model,
})
process.stdout.write(`model ${values.model}: ${describeOptions(switched.configOptions)}\n`)
}

if (values.prompt !== undefined) {
const pending = client.prompt({ sessionId, prompt: [{ type: 'text', text: values.prompt }] })
if (values['cancel-after'] !== undefined) {
Expand Down
Loading