fix(chat): remember model, effort and mode per agent on the host

Merged
HA
16h ago
+1240-203

Links

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 in SessionView). 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_selections table in the host's chat.db (harness, category ∈ model | thought_level | mode, value), with one row per agent and category, so it stays small. SelectionMemory reads and writes it. A write is best effort and logs on failure.
  • Recording: commands.setConfigOption and commands.setMode record a value once the agent accepts it. Adapters' setMode now resolves whether the mode was taken. Plan mode is never recorded as a starting mode, the same rule savedChatMode had. createSession also records its explicit picks when the client sets the new optional rememberSelections flag (used today only by the migration below).
  • Starting: in createSession, later sources win: saved choices (new chats only) → the replaced chat's journaled selections → explicit modelId/effortId/modeId.
    • Mode is gated: the saved mode applies only when interactive is 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.
  • Applying: AcpAdapter.applyStartSelections sets the model, then the effort once the model change is answered, because effort levels depend on the model. Matching uses the existing startOptionId, so stale ids are ignored and the agent's default is kept.
  • Shared shape: ChatSelections (modelId/effortId/modeId) and sessionSelections(state) in @superset/chat/core replace the scattered mode-only handling. HarnessStartOptions, HarnessFactoryOptions and UnfinishedSession are built on it.
  • Desktop migration: on a new chat, a mode still saved in chatModeByAgent is sent as the start mode with rememberSelections: true. The desktop deletes its entry only when the host is at or above CHAT_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; chatModeByAgent entry is then gone
  • Enter plan mode, then open a new chat → it does not start in plan

Testing

  • bunx tsc --noEmit in packages/chat, packages/chat-runtime, packages/host-service, apps/desktop (desktop needed --max-old-space-size=6144 here)
  • bunx biome check on all changed files
  • bun test: chat 108/108, chat-runtime 457/457, desktop 9397/9397
  • host-service 2722 pass / 23 fail. The same 23 fail on a clean main checkout in this sandbox (workspace naming sessions and a health.check stamp test), so they're unrelated.
  • Mobile tsc fails here because @superset/host-service/router declarations 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 mode
    • acpAdapter.test.ts: start effort is sent only after the start model is answered
    • widened sessionInfoFromState / sessionInfoPaneData tests: the pane records model and effort, not just mode

Compatibility Matrix

  • createSession / rewindSession gain an optional effortId. 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_VERSION or newer. Effort and the new host memory are simply absent. MIN_HOST_SERVICE_VERSION is 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.
  • interactive is 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.md asks for schema migrations in their own PR, but that rule is for the cloud packages/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 rememberSelections are 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 grace SessionView'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.
  • isSelectionCategory checks own keys only.

Not changed:

  • Dropped chatModelLabel in saved panes: it was only ever written alongside chatModelId, which the adapter maps onto the agent's options, so saved panes keep their model.
  • Forward effortId to the native Codex adapter: chat presets use codex-acp; the native adapter never supported effort, so this is a separate, existing gap.
  • Clear chatEffortId when 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 acp honour model.json recents.

Rollback

  • Revert the PR. The chat_selections table 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.