fix(sessions): scope the launch model to claude and pin it with the advisor (#514, #515, #530 landing)

Maintainer merge-time fixes for the three PRs that landed together on the
session create / launch / persistence path.

#514 findings (bot verdict merge-with-fixes):
- minor, fixed: SessionState.model was published and persisted for every
  mode, so a codex/opencode cron session reported the app-wide Claude
  default it never ran on. toState() now emits it only where the new
  cliTakesSessionModel() holds (registry capability model.source ===
  'claude-settings-file', no CLI id branch). POST /api/sessions uses the
  same helper for its non-claude refusal, so refusal and publication cannot
  drift. Recovery then hands back undefined for other modes on its own.
- nit, fixed: the `model` schema admitted a leading dash (and '.', '[').
  The first character must now be a letter or digit; still a subset of the
  registry's model-claude pattern, so nothing accepted is refused at launch.
- nit, fixed (reject, the consistent choice): `model` with
  attachRemoteSession was silently dropped. Now a 400 INVALID_INPUT, as
  #514 does for non-claude CLIs and quick-start does for remote cases.
  advisorModel (#530) gets the same refusal there. effort and envOverrides
  keep their older silent ignore on that branch so no existing caller breaks.

#515 finding (bot verdict merge, one nit):
- nit, fixed: the types/session.ts @fileoverview described CodexConfig as
  (model, resumeSessionId); it now lists reasoningEffort, bypass,
  animations and renderMode too.

Audit of the merged combination (not reviewed before):
- The conflict resolutions in session.ts (toState), types/session.ts,
  reboot-restore-routes.ts, server.ts (restoreMuxSessions), CLAUDE.md and
  skills/codeman/reference/endpoints.md (+ plugin mirror) keep both sides
  correctly; nothing was lost or doubled.
- A claude session with both `model` and `advisorModel` launches with
  `--model <id>` and ONE merged `--settings` JSON (ultracode + advisorModel,
  or advisorModel beside `--effort <level>`), on the tmux template
  (including the resume || new variant and with the statusLine exporter)
  and on the direct-PTY fallback. Both values (and effort) survive
  restoreMuxSessions onto a dead pane, a reboot restore into a fresh pane,
  and restartCli/dead-pane respawn via _buildRespawnPaneOptions.
- quick-start and ralph-loop take no per-session `model` (matching #514's
  scope, POST /api/sessions only) and launch on the app-wide default, which
  toState now persists for claude, so recovery stays consistent.
- No defect found in the combination beyond the findings above. Noted, not
  changed: advisorModel is still published for any mode a caller sends it
  with (launch-inert there; the UI and skill send it for claude only).

Tests: test/session-model-recovery.test.ts pins the pair through both
recovery shapes for effort ultracode/high/none, the recovery constructors'
fields, the tmux-manager builder hop, and the codex/opencode/shell
non-publication; test/advisor-model.test.ts pins the launch lines and a
real direct-PTY Session's pty.spawn argv; the route test covers flag-shaped
models, attach refusals and the published fields. Docs: SessionState.model
docstring, the reboot-restore-registry header, the golden test comment and
the CLAUDE.md model/advisor bullets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-04 23:52:41 +02:00
parent 7917273188
commit 06c4c7da16
10 changed files with 365 additions and 18 deletions
+108 -1
View File
@@ -12,7 +12,7 @@
* assertions see exactly what a spawned pane would.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
@@ -27,6 +27,23 @@ import {
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
/**
* What the direct-PTY fallback hands `pty.spawn`. The file and argv are recorded, then a
* harmless stand-in runs instead: the real `claude` must never start from a test, and the
* stand-in is a real process so `Session.stop()` has a real pid to signal.
*/
const ptySpawns = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
vi.mock('node-pty', async (importOriginal) => {
const real = await importOriginal<typeof import('node-pty')>();
return {
...real,
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
ptySpawns.push({ file, args: Array.isArray(args) ? args : [args] });
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
},
};
});
function extractSettingsJson(cmd: string): unknown {
const idx = cmd.indexOf('--settings ');
expect(idx).toBeGreaterThan(-1);
@@ -164,6 +181,96 @@ describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
});
});
/**
* #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying
* both must launch with `--model <id>` AND the advisor folded into the one `--settings` JSON,
* whatever the effort, on the tmux template and on the direct-PTY fallback alike. Recovery of
* the same pair is pinned in test/session-model-recovery.test.ts.
*/
describe('a per-session model together with an advisor', () => {
const MODEL = 'claude-fable-5-1';
const cases = [
['ultracode', { ultracode: true, advisorModel: 'opus' }],
['high', { advisorModel: 'opus' }],
[undefined, { advisorModel: 'opus' }],
] as const;
it.each(cases)('tmux template carries --model and the merged --settings, effort %s', (effort, settings) => {
for (const resumeSessionId of [undefined, '11111111-2222-3333-4444-555555555555']) {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
model: MODEL,
effort,
advisorModel: 'opus',
resumeSessionId,
claudeCliVersion: null,
});
// The resume variant renders `resume || new`, so the model appears once per branch.
expect(cmd).toContain(`--model "${MODEL}"`);
expect(cmd).not.toContain('--advisor ');
const settingsFlags = cmd.match(/--settings /g) ?? [];
expect(settingsFlags.length).toBe(resumeSessionId ? 2 : 1);
expect(extractSettingsJson(cmd)).toEqual(settings);
if (effort === 'high') expect(cmd).toContain("--effort 'high'");
}
});
it('keeps the statusLine exporter in the same object beside the model', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
model: MODEL,
effort: 'ultracode',
advisorModel: 'fable',
statusLineCommand: EXPORTER_CMD,
claudeCliVersion: null,
});
expect(cmd).toContain(`--model "${MODEL}"`);
expect(cmd.match(/--settings /g)).toHaveLength(1);
expect(extractSettingsJson(cmd)).toEqual({
ultracode: true,
advisorModel: 'fable',
statusLine: { type: 'command', command: EXPORTER_CMD },
});
});
it.each(cases)('direct-PTY args carry --model and the merged --settings, effort %s', (effort, settings) => {
const args = buildInteractiveArgs('sid', 'normal', MODEL, undefined, effort, undefined, null, 'opus');
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
if (effort === 'high') expect(args).toEqual(expect.arrayContaining(['--effort', 'high']));
});
describe('a real Session on the direct-PTY fallback', () => {
const live: Session[] = [];
afterEach(async () => {
for (const s of live.splice(0)) await s.stop();
ptySpawns.length = 0;
});
it.each(cases)('hands pty.spawn both, effort %s', async (effort, settings) => {
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: false,
model: MODEL,
advisorModel: 'opus',
effort,
});
live.push(session);
await session.startInteractive();
expect(ptySpawns).toHaveLength(1);
const { args } = ptySpawns[0];
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
});
});
});
describe('Session advisorModel', () => {
it('stores a valid advisor and persists it through toState()', () => {
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });