mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
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:
co-authored by
Claude Opus 5.5
parent
4123d229f4
commit
3df113fc54
@@ -54,6 +54,19 @@ describe('claude', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
|
||||
// POST /api/sessions admits a leading '-' in `model`. It still lands as the option's
|
||||
// value: quoted here, and Claude's option parser takes the word after `--model` as its
|
||||
// value whatever it starts with, so it can never become a flag of its own.
|
||||
expect(claude({ model: 'claude-fable-5-1' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
|
||||
);
|
||||
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--model "--dangerously-skip-permissions"'
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes through a shell fallback to a fresh session', () => {
|
||||
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
|
||||
// text. This pin is what proves the fallback chain still renders as one command line.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @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,/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user