Files
Codeman/test/session-model-recovery.test.ts
T
Michael GrundbergandClaude Opus 5.5 3df113fc54 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>
2026-10-01 17:24:12 +02:00

72 lines
3.0 KiB
TypeScript

/**
* @fileoverview A session's launch model survives recovery.
*
* `Session._model` is what becomes `claude --model <id>`: the caller's per-session `model`
* from POST /api/sessions, or the app-wide default. It lives in memory, so it reaches a
* relaunch after a Codeman restart or a reboot restore only if `toState()` persists it and
* both recovery constructors hand it back. Without that, a recovered session silently
* relaunches on the account default.
*
* `restoreMuxSessions()` (server.ts) cannot be reached under vitest, where
* `reconcileSessions()` reports every pane alive, and the reboot-restore route rejects every
* workspace before building a Session in its route tests. The two constructors are therefore
* pinned by a source check, the same way `test/remote-wake.test.ts` pins its wiring, and the
* round trip itself is driven through a real `Session` against the in-memory tmux layer.
*/
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
const SRC = fileURLToPath(new URL('../src', import.meta.url));
describe('the launch model survives recovery', () => {
const workingDir = join(homedir(), 'codeman-cases', 'session-model-recovery');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(workingDir, { recursive: true, force: true });
});
it('persists the model in the session state', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude', model: 'claude-fable-5-1' });
sessions.push(session);
expect(session.toState().model).toBe('claude-fable-5-1');
});
it('relaunches a session rebuilt from that state on the same model', async () => {
mkdirSync(workingDir, { recursive: true });
const original = new Session({ workingDir, mode: 'claude', model: 'claude-fable-5-1' });
sessions.push(original);
const state = original.toState();
// Rebuilt the way both recovery paths build one, from the persisted record.
const mux = new TmuxManager();
const createSession = vi.spyOn(mux, 'createSession');
const rebuilt = new Session({
id: state.id,
workingDir,
mode: state.mode,
mux,
useMux: true,
model: state.model,
});
sessions.push(rebuilt);
await rebuilt.startInteractive();
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-fable-5-1' }));
});
it('is handed back by both recovery constructors', () => {
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
expect(server).toMatch(/model:\s*savedState\?\.model,/);
expect(reboot).toMatch(/model:\s*saved\.model,/);
});
});