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
@@ -75,12 +75,30 @@ describe('POST /api/sessions model', () => {
expect(await launchedModel({ mode: 'claude' })).toBe('sonnet');
});
it('writes no model into the case directory', async () => {
// The create still installs Codeman's workspace hooks into settings.local.json, so the
// file exists; what must not be in it is a model that would outlive this session.
await launchedModel({ mode: 'claude', model: 'opus' });
const settings = await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8').catch(() => '{}');
expect(JSON.parse(settings)).not.toHaveProperty('model');
it('launches on `model` while `modelOverride` alone reaches the case file', async () => {
// Sent together, each lands where it belongs: the persistent default in the case's
// settings.local.json, and this session's model on its launch line. A route that wrote
// `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'.
expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus');
const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8'));
expect(settings.model).toBe('sonnet');
});
it('reads an empty model as no model, as modelOverride does', async () => {
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet');
});
it('refuses a model for a CLI that takes its model in its own config object', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'codex', model: 'gpt-5' },
});
const parsed = JSON.parse(res.body);
expect(parsed.success).toBe(false);
expect(parsed.errorCode).toBe('INVALID_INPUT');
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
});
it('rejects a model with characters the launch pattern refuses', async () => {