fix(sessions): keep a session's model through recovery, refuse it off claude

SessionState now carries the model a session launched with, and both
recovery constructors (mux recovery and reboot restore) pass it back, so a
recovered session relaunches on the same --model rather than the account
default. A top-level `model` sent with any other CLI is refused, since
those take their model in their own config object, and an empty string
means no per-session model, as it does for modelOverride.

CLAUDE.md now describes both routes for a Claude model. The tests pin
which of `model` and `modelOverride` reaches the launch and which the
case file, and that a model opening with a dash renders as --model's value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-10-01 17:24:12 +02:00
co-authored by Claude Opus 5.5
parent 4123d229f4
commit 3df113fc54
12 changed files with 136 additions and 12 deletions
+1
View File
@@ -1827,6 +1827,7 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
model: this._model,
customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored
+7
View File
@@ -795,6 +795,13 @@ export interface SessionState {
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/**
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
* the app-wide default when there was none. Persisted so a recovered session relaunches on
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
* the CLI's banner reports.
*/
model?: string;
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
+1
View File
@@ -195,6 +195,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
+9
View File
@@ -888,6 +888,15 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
const body = parseBody(CreateSessionSchema, req.body);
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
// dropped without a word; refuse it before anything is written for the session.
if (body.model && getCli(body.mode ?? 'claude')?.capabilities.model.source !== 'claude-settings-file') {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
);
}
let workingDir = body.workingDir || process.cwd();
let remote = undefined;
+4 -1
View File
@@ -529,12 +529,15 @@ export const CreateSessionSchema = z.object({
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
* disk. Wins over the app-wide default model. Same character set as the registry's
* `model-claude` pattern, so a value accepted here is never rejected at launch.
* `model-claude` pattern, so a value accepted here is never rejected at launch. An empty
* string means no per-session model, as it does for `modelOverride`. Claude only: the route
* refuses it for any other CLI.
*/
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-[\]]+$/)
.or(z.literal(''))
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
+1
View File
@@ -3489,6 +3489,7 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though