* feat(cli): restore each daemon session onto its last selected model Idle detach currently rebuilds Config from settings.model.name, so session A picks up whatever model session B last switched to. Fixes #9686 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): address session-model persistence review findings - reader: always select the last assistant record into the restore read set so the legacy lastAssistantModel fallback still fires when a trailing chat_compression candidate excludes it from the resume read - recorder: assign currentSessionModel before the awaited write so a rewind landing in the pending-write window re-anchors the new binding instead of the stale one - reader/recorder: reject non-string session_model payload fields instead of crashing the restore path on malformed transcripts - protocol doc: describe the session_model append as best-effort, not an unconditional consequence of a successful switch - cli: import RUNTIME_SNAPSHOT_PREFIX/stripRuntimeSnapshotPrefix from core instead of duplicating the prefix algorithm locally - tests: pin the isRuntime/baseUrl payload dimension, the prefix and route-mismatch false arms, the neither-field fallback, and regression coverage for the two fixes above * fix(cli): keep daemon session-model restore from failing load Pre-auth restore skipped the last-assistant fallback, and a recorded qwen-oauth binding could hard-fail load when cached credentials were gone. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): roll session-model auth retry back onto the settings route Same-id baseUrl restores and runtime-only settings models were skipping or breaking the fallback, which made load fail on the recorded credential set. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): keep empty daemon sessions from creating a transcript Recording the session model on newSession wrote a jsonl file before any user content, so close/delete/child-death left the id occupied and listing still showed the empty session. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): allowlist restored session-model routes against the registry JSONL baseUrl is only a registry selector, so unknown hosts are dropped before switchModel. Restore also keeps the last valid session_model payload instead of falling through a torn trailing record. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): retry session-model auth after same-id snapshot restore The retry gate ignored runtime-snapshot identity, so restoring an implicit registry route off a same-id snapshot looked unchanged and skipped rollback. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
4 KiB
Daemon session model persistence
Status
Implementation companion to keeping a daemon session on the model it was created or last switched to, across detach / idle reap / daemon restart.
Problem
Each ACP session has its own in-memory Config, but Session.setModel (and
ACP /model) also write settings.model.name. Switching away from an idle
session typically detaches and closes it. The next load/resume builds a new
Config from current settings, so session A picks up model B.
Assistant JSONL records store model per turn, but restore does not apply it.
Goals
- Daemon load/resume of an existing session restores that session's model.
- New sessions still inherit the last persisted
model.namedefault. - TUI and CLI
--resumedo not switch models from this record. - Resume stays read-only (no JSONL append).
Non-goals
- Restoring model in TUI / CLI
--resume. - Changing approval-mode persistence.
- Stopping
model.nameupdates for new-session defaults. - Rebinding idle live sessions after
workspaceReload.
Record format
Append-only system / session_model JSONL records, last-wins, same pattern
as session_source.
interface SessionModelRecordPayload {
modelId: string;
authType: string;
baseUrl?: string;
isRuntime?: boolean;
}
modelId is the canonical id after switchModel (no ACP route id, no
$runtime| prefix). Runtime selections store the underlying id with
isRuntime: true.
Write sites (daemon user intent only)
All writes go through ChatRecordingService.recordSessionModel (best-effort,
identical payload is a no-op), except rewind: rewindRecording re-appends the
in-memory binding after the rewind record so last-wins on the active branch
still matches Config.
Session.setModelafter a successful switch (includingpersistDefault: false).- ACP
/model <id>viaswitchMainModelwhenexecutionMode === 'acp'. rewindRecording, which re-anchors the live binding (not a user switch).
acpAgent.newSession must not write. Empty daemon sessions have no transcript
file; listing, DELETE, and child death all depend on that. A new session
already inherits settings.model.name. Load/resume of a session that never
switched models uses the last assistant model, else the current settings
default. A session that has user records but no assistant record and was never
switched therefore has no binding; that residual window is accepted so empty
sessions stay file-less.
loadSession / resumeSession must not write. workspaceReload must not
write.
Implicit registry records omit baseUrl and isRuntime. Restore must still
switchModel when the cold Config currently holds a same-id runtime snapshot,
so the session leaves the snapshot endpoint instead of no-op'ing.
Restore (ACP cold start only)
Live attach/resume skips restore. Cold loadSession / resumeSession:
newSessionConfigstill constructs Config from current settings.- Before
ensureAuthenticated, apply the last validsession_modelpayload, else the last assistantmodel(same auth frommodelsConfig.getCurrentAuthType()when content-generator auth is not yet populated), else keep settings. A recordedbaseUrlis a registry route selector, not an arbitrary endpoint: it is honored only when it matches a configured registry route for that auth type and model, otherwise the implicit registry route is used.authTypemust be a knownAuthType. switchModelfailure is non-fatal. A recorded runtime-snapshot binding whose live snapshot is gone still switches the bare id when a registry route exists; that can be a different endpoint than the recorded binding. Restore continues on the settings default only when no route resolves. If the restored auth then failsensureAuthenticated, load/resume reverts to the settings model and retries authentication once.
Surfaces
JSONL is shared, so core must accept the subtype. Replay already skips ordinary
system records. Only ACP applies switchModel on restore.