fix(chat): remember model, effort and mode per agent on the host
Links
- Issue: SUPER-3167 — new OpenCode worktrees default to Crouton Alpha Free in chat, while the CLI uses DeepSeek V4.1 Flash
- Related: SUPER-3049 (this is its "store the last-used model and effort" half; the picker's "Default" option is not touched)
- Diagnosis: https://app.superset.sh/page/super-3167-why-chat-ui-defaults-opencode-to-crouto-pno8az
- Builds on #9354 (mode kept across resume/fork/recovery)
Summary
- The host now remembers the model, effort and mode a user last set in a chat, per agent, and new chats start on them. Every client that creates chats through the host (desktop, mobile, CLI, automations) gets this without its own change.
- Recovery, fork, rewind and resume-after-update keep the source chat's model and effort as well as its mode. Before, a recovered desktop chat went back to its launch model, and an ACP chat lost its model on a host update.
- Desktop stops keeping its own copy (localStorage
chatModeByAgent,chatModelLabel, and the label-based model pass inSessionView). The pane records what the session runs on and sends it back on restart.
Why / Context
ACP's session/set_config_option changes a setting for one session only (verified in OpenCode's ACP server, cli/src/acp/sessions.ts select). No agent persists it. With no saved choice, a new chat ran on the agent's own default. For opencode acp that is the newest released model (core/src/model.ts Model.default falls back to available[0], sorted newest-first), which today is Crouton Alpha Free. OpenCode's TUI checks the recent models in ~/.local/state/opencode/model.json first, which is why the terminal looked right. Rule adopted: the agent decides what options exist, and Paradev remembers what the user chose.
How It Works
- Storage: new
chat_selectionstable in the host'schat.db(harness,category∈model | thought_level | mode,value), with one row per agent and category, so it stays small.SelectionMemoryreads and writes it. A write is best effort and logs on failure. - Recording:
commands.setConfigOptionandcommands.setModerecord a value once the agent accepts it. Adapters'setModenow resolves whether the mode was taken. Plan mode is never recorded as a starting mode, the same rulesavedChatModehad.createSessionalso records its explicit picks when the client sets the new optionalrememberSelectionsflag (used today only by the migration below). - Starting: in
createSession, later sources win: saved choices (new chats only) → the replaced chat's journaled selections → explicitmodelId/effortId/modeId.- Mode is gated: the saved mode applies only when
interactiveis set, which only the chat router sets (desktop/mobile chat panes). Automations, CLI and New Workspace launches keep today's behaviour unless they pass a mode, because nobody is watching to answer an approval. Saved model and effort apply to every new chat, which is what fixes the New Workspace case in SUPER-3167.
- Mode is gated: the saved mode applies only when
- Applying:
AcpAdapter.applyStartSelectionssets the model, then the effort once the model change is answered, because effort levels depend on the model. Matching uses the existingstartOptionId, so stale ids are ignored and the agent's default is kept. - Shared shape:
ChatSelections(modelId/effortId/modeId) andsessionSelections(state)in@superset/chat/corereplace the scattered mode-only handling.HarnessStartOptions,HarnessFactoryOptionsandUnfinishedSessionare built on it. - Desktop migration: on a new chat, a mode still saved in
chatModeByAgentis sent as the start mode withrememberSelections: true. The desktop deletes its entry only when the host is at or aboveCHAT_SELECTIONS_MIN_HOST_VERSION. An older host drops the unknown flag and keeps getting the mode from the desktop, as before.
Manual QA Checklist
Not run in this cloud workspace: it has no OpenCode install and no desktop display. Please check:
- OpenCode chat: pick DeepSeek V4.1 Flash, open a new worktree with chat → it starts on DeepSeek, not Crouton Alpha Free
- Claude chat: set effort High, open a new chat → model and effort carry over
- Switch model mid-chat, quit and relaunch the app → the chat resumes on the switched model, not the launch model
- Set "Ask for approval" in a chat, then run an automation on the same agent → the automation does not start in Ask mode
- User with an existing saved non-default mode from an older build → first new chat starts in it;
chatModeByAgententry is then gone - Enter plan mode, then open a new chat → it does not start in plan
Testing
bunx tsc --noEmitinpackages/chat,packages/chat-runtime,packages/host-service,apps/desktop(desktop needed--max-old-space-size=6144here)bunx biome checkon all changed filesbun test: chat 108/108, chat-runtime 457/457, desktop 9397/9397- host-service 2722 pass / 23 fail. The same 23 fail on a clean
maincheckout in this sandbox (workspace naming sessions and ahealth.checkstamp test), so they're unrelated. - Mobile
tscfails here because@superset/host-service/routerdeclarations aren't built. None of the errors touch changed code. - New tests, each confirmed to fail without its fix:
commandsSelections.test.ts: a new chat starts on the remembered picks and an explicit pick wins; a chat nobody opened gets model+effort but not mode; a replacement resumes on the replaced chat's model, effort and modeacpAdapter.test.ts: start effort is sent only after the start model is answered- widened
sessionInfoFromState/sessionInfoPaneDatatests: the pane records model and effort, not just mode
Compatibility Matrix
createSession/rewindSessiongain an optionaleffortId. An old host drops it, and the effort is then not applied.- Old desktop → new host: keeps sending its localStorage mode and launch model as explicit choices, which still win. It gains host memory for anything it doesn't send.
- New desktop → old host: the desktop keeps its saved mode and sends it on every new chat, as today; it only deletes it once a host reports
CHAT_SELECTIONS_MIN_HOST_VERSIONor newer. Effort and the new host memory are simply absent.MIN_HOST_SERVICE_VERSIONis not raised. CHAT_SELECTIONS_MIN_HOST_VERSION = "2.0.6"assumes this ships in the next release after 2.0.5. Bump it if the release that carries this PR gets a different version.interactiveis a host-internal command field, not part of the tRPC input, so clients can't set it.
Design Decisions
- Host, not renderer storage: one memory for desktop, mobile, CLI and automations, plus journal access for resume paths.
- Mode gated on
interactive, model/effort not: a remembered "ask" mode would stall a headless run. A remembered model only changes which model runs. - Migration in this PR:
packages/trpc/AGENTS.mdasks for schema migrations in their own PR, but that rule is for the cloudpackages/db. This is the host's local SQLite, shipped with the code that reads it, so splitting it out gains nothing.
Review follow-up
superset-home (fixed in 8437473e): the mode is saved only after the agent accepts it; rewindSession lets explicit values win; SelectionMemory.get is best effort; the legacy mode is kept until the host is new enough to store it (CHAT_SELECTIONS_MIN_HOST_VERSION, via rememberSelections).
cubic / CodeRabbit inline comments (fixed in 3a357456, each with a test that fails without it):
- Picks sent with
rememberSelectionsare saved only once the agent reports it took them (AcpAdapter.startSelections()), storing the agent's own id. - A pick that settles after a newer one from the same chat no longer overwrites it.
- The start effort is skipped when the agent refuses the start model.
- Start selections wait up to 1 s for an agent that lists its options after
session/new; the first prompt waits with them. This replaces the graceSessionView's label pass used to give. - Rewinding a stopped chat carries its journaled selections.
- The terminal-to-chat fork loader no longer depends on the legacy localStorage mode: an interactive resume outside a journal starts in the remembered mode, and the new
fallbackModeId(the harness default) applies only when nothing else names one. - A model picked in the agent switcher is remembered: desktop marks every new chat's explicit picks as the user's.
isSelectionCategorychecks own keys only.
Not changed:
- Dropped
chatModelLabelin saved panes: it was only ever written alongsidechatModelId, which the adapter maps onto the agent's options, so saved panes keep their model. - Forward
effortIdto the native Codex adapter: chat presets usecodex-acp; the native adapter never supported effort, so this is a separate, existing gap. - Clear
chatEffortIdwhen a model offers no effort: the adapter finds no effort option to apply, so a stale id is ignored. - Agent switch from a restricted chat sends
agent-default: intended; it keeps "never more access than the chat being left". Before this PR the target's saved mode could override that.
Known Limitations
- New Workspace keeps its own model/effort/mode prefs (
useAgent*Preference) and sends them as explicit picks. Moving that picker to the host's memory is SUPER-3049's "remove Default" work. - First-ever OpenCode chat (nothing saved yet) still starts on OpenCode's ACP default. Upstream fix: make
opencode acphonourmodel.jsonrecents.
Rollback
- Revert the PR. The
chat_selectionstable stays but is unused; it is harmless. - Users who already moved their saved mode to the host lose it on rollback (localStorage entry deleted), and new chats start in the agent default.
Summary by CodeRabbit
- New Features
- New chats can reuse the most recently selected model, effort level, and starting mode for the same chat engine where supported. Choices made for a new chat take precedence.
- Replacement chats retain the replaced chat’s model, effort level, and mode. Forked and rewound chats retain their model and effort selections.
- Improvements
- Pending prompts are sent as soon as a session is ready, without waiting for model configuration to finish.