Files
Codeman/test/session-custom-model-restart.test.ts
T
Michael GrundbergandClaude Opus 5 1cb0441bd8 fix(session): degrade the resume pin to the session id, not to nothing
A single pin that failed its transcript gate returned the options untouched,
so `resumeSessionId` fell back to `_resumeSessionId` — undefined for an
ordinary session — and the renderer emitted the bare
`claude --dangerously-skip-permissions --session-id "<this.id>"`. Every
session prompted before its first `/clear` owns a transcript under that id,
so the dropped pin handed back exactly the refusal this branch removes, with
no `||` branch to catch it. It was also a regression against master on the
`restartCli()` path, which pinned `_claudeSessionId ?? this.id` and, since the
constructor seeds that field, could never land unpinned.

The pin now walks three candidates in priority order — the conversation
chain's tail, the launch seed, then the session's own id — and takes the first
one a transcript backs. A candidate that misses is passed over rather than
ending the walk.

Falling off the end pins nothing, which also settles the second half of the
problem: the old code skipped the transcript check whenever the pin was the
session's own id, so a genuinely new pane rendered the two-branch form after
all. That costs a brand-new session claude's "No conversation found" line in
its scrollback, and `wrapWithNice()` prefixes only the first branch of the
rendered `a || b`, so the branch that actually runs loses its priority for the
life of the session. With no transcript anywhere the bare `--session-id` is
the correct command, so the comment claiming an unchanged shape is now true.

The transcript lookup reads the server process's own `CLAUDE_CONFIG_DIR` when
a session declares none. A pane inherits the server environment through tmux,
so on an install that exports it the CLI writes its transcripts there and
every lookup under `~/.claude` was a false negative — which under the old code
meant the colliding command. `claudeCredentialsPath()` and
`realClaudeConfigDir()` resolve the same directory the same way. The header
sentence calling a skipped resume "the safe direction" described the opposite
of what happens at this call site, and says so now.

The create-path fallback writes `_resumeSessionId` alongside the create
options. That branch leaves `isRestored` false, so `_claudeSessionId` is
recomputed from the launch fields and settled on `this.id` while the CLI
resumed the chain tail; the response viewer, Read My Mind and the unified-list
alias map read that field until the next first-hand hook.

Four new tests: a chain tail with no transcript while the session id has one,
no transcript anywhere, the create path's alias, and the process-env lookup.
All four fail against the previous commit. Two existing tests move with the
gate — the guess-refusal test now backs the session's own id, and the
custom-model restart test gives its working pane the transcript that makes
`--session-id` collide in the first place, alongside a new one pinning the
no-transcript case.

CLAUDE.md described the pin as a `restartCli()`-only thing sourced from the
live conversation id. All three halves of that moved here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:21:21 +02:00

228 lines
10 KiB
TypeScript

/**
* @fileoverview Custom Model Endpoint Profiles, the Session half of applying and
* clearing a selection against a live pane (`Session.setCustomModel()` +
* `Session.restartCli()`), pinned against the three ways the first cut broke a
* working session:
*
* 1. Clearing did not clear. The injected vars reach the CLI via `tmux setenv`,
* which persists at the tmux-session level and is inherited by `respawn-pane`,
* so removing them from `_envOverrides` relaunched the CLI still pointed at the
* endpoint (and, for the configDir kinds, at a `HOME`/`CODEX_HOME` that had just
* been deleted). The retired keys must ride `RespawnPaneOptions.unsetEnvKeys`.
* 2. Applying to a local claude session killed the pane: the relaunch was
* `claude --session-id <id>` and Claude refuses an id that already has a
* transcript, so it needs the `--resume <id> || --session-id <id>` shape the
* docker and remote pane commands use, i.e. a pinned resume id. The pin is
* gated on that transcript existing, so these tests write one.
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
* that selects it, so the file was ignored.
*
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
* spying on `respawnPane` to read the options the relaunch would get.
* Port: N/A.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { MuxSession, SessionMode } from '../src/types.js';
const workingDir = join(homedir(), 'codeman-cases', 'custom-model-restart');
const projectsDir = join(homedir(), '.claude', 'projects', '-custom-model-restart');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(workingDir, { recursive: true, force: true });
// Only the directory these tests create. `setup.ts` gives each test file a
// temp HOME, but a wider sweep here would delete a real `~/.claude` the day
// that stops being true.
rmSync(projectsDir, { recursive: true, force: true });
});
/**
* Write the transcript Claude would have written for a conversation. A pane
* `restartCli()` relaunches is a WORKING one, so its conversation has a
* transcript on disk; that is both what makes the bare `--session-id` collide
* and what the pin is now gated on.
*/
function giveTranscript(conversationId: string): void {
mkdirSync(projectsDir, { recursive: true });
writeFileSync(join(projectsDir, `${conversationId}.jsonl`), '{"type":"user"}\n');
}
function liveSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
mkdirSync(workingDir, { recursive: true });
const mux = new TmuxManager();
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-cafe0001',
pid: 1,
createdAt: Date.now(),
workingDir,
mode,
attached: false,
};
const session = new Session({ workingDir, mode, mux, useMux: true, muxSession, ...extra });
sessions.push(session);
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
return { session, respawn };
}
const CLAUDE_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_DEFAULT_SONNET_MODEL'];
const claudeEnv = {
ANTHROPIC_BASE_URL: 'http://box:8080',
ANTHROPIC_API_KEY: 'k',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'q',
};
describe('clearing a selection unsets what it injected', () => {
it('queues the retired keys for setenv -u on the next respawn and drops them from the overrides', async () => {
const { session, respawn } = liveSession('claude', { envOverrides: { CLAUDE_CODE_KEEP: '1' } });
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
const result = session.setCustomModel(undefined);
expect(result.removedEnvKeys).toEqual(CLAUDE_KEYS);
expect(session.customModel).toBeUndefined();
expect(await session.restartCli()).toBe(true);
const options = respawn.mock.calls[0][0];
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
expect(options.envOverrides).toEqual({ CLAUDE_CODE_KEEP: '1' });
// Drained once the respawn succeeded: the next relaunch has nothing to unset.
respawn.mockClear();
await session.restartCli();
expect(respawn.mock.calls[0][0].unsetEnvKeys).toBeUndefined();
});
it('switching endpoints re-sets the shared keys instead of unsetting them', async () => {
const { session, respawn } = liveSession('claude');
session.setCustomModel({ endpointId: 'a', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
const next = { ...claudeEnv, ANTHROPIC_BASE_URL: 'http://other:8080' };
session.setCustomModel({ endpointId: 'b', modelId: 'q', envKeys: CLAUDE_KEYS }, next);
await session.restartCli();
const options = respawn.mock.calls[0][0];
expect(options.unsetEnvKeys).toBeUndefined();
expect(options.envOverrides?.ANTHROPIC_BASE_URL).toBe('http://other:8080');
});
it('reports the previous config dir so the caller can delete it, and never leaks bookkeeping on the wire', () => {
const { session } = liveSession('pi');
session.setCustomModel(
{ endpointId: 'ep', modelId: 'q', envKeys: ['HOME'], configDir: '/tmp/cfg-1', launchModel: 'custom/q' },
{ HOME: '/tmp/cfg-1' }
);
expect(session.customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
expect(session.toState().customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
expect(session.getCustomModelForPersist()).toEqual({
endpointId: 'ep',
modelId: 'q',
label: undefined,
envKeys: ['HOME'],
configDir: '/tmp/cfg-1',
launchModel: 'custom/q',
});
expect(session.setCustomModel(undefined).previousConfigDir).toBe('/tmp/cfg-1');
});
});
describe('restartCli() must not kill a working pane', () => {
it('claude: pins the live conversation id so the relaunch renders --resume <id> || --session-id <id>', async () => {
const { session, respawn } = liveSession('claude');
giveTranscript(session.id);
await session.restartCli();
const options = respawn.mock.calls[0][0];
expect(options.resumeSessionId).toBe(session.claudeSessionId);
expect(options.resumeSessionId).toBe(session.id);
// The pin is per-respawn: nothing about the session's own resume id changed.
expect(session.toState().resumeSessionId).toBeUndefined();
});
it('claude: pins nothing when the pane has no transcript, because nothing can collide', async () => {
// A pane that was launched and never prompted. `--session-id <this.id>` is
// accepted on an id no transcript holds, so pinning would buy nothing and
// cost two things: claude prints "No conversation found" into a pane with
// no history, and `wrapWithNice()` prefixes only the first branch of the
// rendered `a || b`, so the branch that actually runs loses its priority
// for the life of the session.
const { session, respawn } = liveSession('claude');
await session.restartCli();
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
});
it('claude: an explicit resume id from a resume-from-history launch wins over the pin', async () => {
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
giveTranscript(RESUMED);
await session.restartCli();
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
});
it('claude: a launch seed survives the pin walk even with its transcript gone', async () => {
// The walk only ever ADDS a pin. The seed is what the session was created
// with and every respawn has always carried it, so a transcript deleted
// under a running session leaves the relaunch on
// `--resume <seed> || --session-id <this.id>` — the resume fails and the
// fallback runs, which is safe precisely because nothing is on disk to
// collide with.
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
await session.restartCli();
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
});
it('pi: no top-level resume pin, its resume id is minted by the CLI and lives in piConfig', async () => {
const { session, respawn } = liveSession('pi');
await session.restartCli();
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
});
});
describe('launchModel reaches the CLI through its own launch param', () => {
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
{ HOME: '/tmp/cfg' }
);
await session.restartCli();
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'custom/qwen3', thinking: 'low' });
// The user's own choice survives underneath, which is what a clear falls back to.
expect(session.toState().piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
session.setCustomModel(undefined);
respawn.mockClear();
await session.restartCli();
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
});
it('grok: the block name lands in grokConfig.model', async () => {
const { session, respawn } = liveSession('grok');
session.setCustomModel(
{
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['GROK_HOME'],
configDir: '/tmp/cfg',
launchModel: 'codeman-custom',
},
{ GROK_HOME: '/tmp/cfg' }
);
await session.restartCli();
expect(respawn.mock.calls[0][0].grokConfig).toEqual({ model: 'codeman-custom' });
});
it('claude: a selection without a launchModel leaves the model param untouched', async () => {
const { session, respawn } = liveSession('claude');
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
await session.restartCli();
expect(respawn.mock.calls[0][0].model).toBeUndefined();
});
});