Files
Codeman/test/session-display-model.test.ts
T
Codeman maintainer ecd577157b fix(cli-registry): codex launch defaults as registry data, ultra footer, schema doc defaults
- Codex footer model detection (c28): the modelDetect.screenLine effort
  alternation is now built from CODEX_REASONING_EFFORTS plus 'default', so
  'ultra' (offered by the codexReasoningEffort App Setting and codex's own
  /model picker) is read and the launch enum and the footer reader cannot
  drift again. Still one capture group, 125 characters, no new quantifier.
  New session-display-model case loops every effort level, ultra included.

- No CLI-id branching for launch defaults (c27): the two mode === 'codex'
  branches the synced codex model/effort defaults added to the create and
  quick-start routes are replaced by a registry capability,
  capabilities.launchDefaults (launch param -> settings key, values from a
  closed enum), declared on the codex entry only. The resolver moved from
  web/codex-launch-defaults.ts to web/launch-defaults.ts as
  applyLaunchDefaults(mode, configs, customEndpoint), filling the entry's
  legacyConfigField object through legacyConfigAliases, still re-validating
  with SettingsUpdateSchema and never overwriting a caller's value. The
  route exclusions are unchanged (create: not remote; quick-start: not
  remote, not Docker, not a custom model endpoint), and quick-start still
  derives the session model from a bag without ompConfig, as before.
  schema.ts refuses an undeclared param, an unknown settings key, an empty
  map, and launchDefaults on an entry with no legacyConfigField.

- The no-id-branching guard now carries an exact occurrence count per
  allowlisted key, so a new copy of an already approved expression fails
  instead of riding the old approval, with a synthetic anti-vacuity case.

- SettingsUpdateSchema JSDoc (c21/c29): 'classic' is the tabArrangement
  default and 'compact' the headerStatsStyle default, matching the
  resolvers and the pre-paint script; state/case/ledger are marked opt-in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:35:56 +02:00

690 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Which model a session runs (`SessionState.displayModel`), for the tile grid's and the
* split pane's headers: the pure resolver, the screen read with each CLI's registry
* pattern, and the session that feeds them.
*
* The pane fixtures are verbatim `capture-pane -p` rows (trailing blanks trimmed) from
* live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex
* 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins.
* The opencode 1.3.0 composer rows are from 2026-10-09 captures.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex, countCaptureGroups } from '../src/config/cli-registry/patterns.js';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import {
MAX_DISPLAY_MODEL_CHARS,
launchModelFor,
readScreenModel,
resolveDisplayModel,
restoredReportedModel,
sanitizeModelName,
} from '../src/session-display-model.js';
import { IDLE_SILENCE_MS } from '../src/session-activity.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
const DSH_ROWS = detectOf('deepseek').screenLines;
const DSH_REJECT = detectOf('deepseek').rejectWords;
const CODEX = compileVersionRegex(detectOf('codex').screenLine)!;
const CODEX_ROWS = detectOf('codex').screenLines;
const PI = compileVersionRegex(detectOf('pi').screenLine)!;
const PI_ROWS = detectOf('pi').screenLines;
const PI_REJECT = detectOf('pi').rejectWords;
const DSH_BORDER_TOP = '╭' + '─'.repeat(95) + '╮';
const DSH_BORDER_BOTTOM = '╰' + '─'.repeat(95) + '╯';
const DSH_COMPOSER = '❯' + ' '.repeat(95) + '⛶';
/** The foot of a dsh-TUI pane: transcript, the composer's rounded box, the status line. */
function dshPane(statusLine: string | null, transcript: string[] = []): string {
return [
' Tip: Footer compact on = one merged line; off = metric…',
' Explore the uncharted!',
' ▶ (Ctrl+P to expand) Context loaded · System prompt 16 sections · Runtime context 2 items · …',
...transcript,
DSH_BORDER_TOP,
DSH_COMPOSER,
DSH_BORDER_BOTTOM,
...(statusLine === null ? [] : [statusLine]),
'',
].join('\n');
}
const DSH_LIVE = dshPane(' qwen3.8-27b · medium · th-scratch');
/**
* The foot of a codex pane: transcript, composer, then the status line, and from 0.162.0
* on a hint row under it at rest (`hint`; absent while a prompt is being typed).
*/
function codexPane(statusLine: string | null, transcript: string[] = [], hint: string | null = null): string {
return [
'│ directory: ~/codeman-cases/th-scratch │',
'│ permissions: YOLO mode │',
'╰───────────────────────────────────────────────╯',
' Tip: New For a limited time, Codex is included in your',
' plan for free – let’s build together.',
...transcript,
'› Explain this codebase',
'',
...(statusLine === null ? [] : [statusLine]),
...(hint === null ? [] : [hint]),
'',
].join('\n');
}
const CODEX_LIVE = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch');
const CODEX_154 = codexPane(
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left'
);
/** 0.162.0 at rest, verbatim from the 1.36.0 beta pane whose tile showed no model. */
const CODEX_162 = codexPane(' GPT-6-Luna default · ~/codeman-cases/testcase', [], ' ← for agents · ? for shortcuts');
describe('the registry patterns', () => {
it('compile through compileVersionRegex() with exactly one capture group', () => {
for (const mode of ['deepseek', 'codex', 'pi']) {
const { screenLine } = detectOf(mode);
expect(compileVersionRegex(screenLine), mode).not.toBeNull();
expect(countCaptureGroups(screenLine), mode).toBe(1);
}
});
it('are declared only where a footer was measured (claude reports through its statusline)', () => {
expect(getCli('claude')!.capabilities.modelDetect).toBeUndefined();
expect(getCli('shell')!.capabilities.modelDetect).toBeUndefined();
});
it('the schema refuses a pattern with no capture group, two of them, or one it will not run', () => {
const codex = getCli('codex')!;
const withDetect = (modelDetect: unknown) =>
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
expect(withDetect({ screenLine: '^ {2}([a-z]+) · ' })).toBe(true);
expect(withDetect({ screenLine: '^ {2}[a-z]+ · ' })).toBe(false);
expect(withDetect({ screenLine: '^ {2}([a-z]+) (high) · ' })).toBe(false);
expect(withDetect({ screenLine: '(a+)+$' })).toBe(false);
expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false);
});
it("dsh declares what its footer's first field can be when it is not the model", () => {
// Every effort id dsh's adapters offer, and the shipped mode ids.
expect(DSH_REJECT).toEqual(
expect.arrayContaining(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'])
);
expect(detectOf('codex').rejectWords).toBeUndefined();
});
it('the schema takes single-token reject words with a screenLine only, and bounds them', () => {
const codex = getCli('codex')!;
const withDetect = (modelDetect: unknown) =>
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
const screenLine = '^ {2}([a-z]+) · ';
expect(withDetect({ screenLine, rejectWords: ['medium'] })).toBe(true);
expect(withDetect({ configResolver: 'deepseek-route', rejectWords: ['medium'] })).toBe(false);
expect(withDetect({ screenLine, rejectWords: ['two words'] })).toBe(false);
expect(withDetect({ screenLine, rejectWords: Array.from({ length: 33 }, (_, i) => `w${i}`) })).toBe(false);
});
it('dsh also names a config reader for while its screen names no model', () => {
expect(detectOf('deepseek').configResolver).toBe('deepseek-route');
expect(detectOf('codex').configResolver).toBeUndefined();
});
it('the schema takes a known config reader alone, and refuses an empty or unknown one', () => {
const codex = getCli('codex')!;
const withDetect = (modelDetect: unknown) =>
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
expect(withDetect({ configResolver: 'deepseek-route' })).toBe(true);
expect(withDetect({ configResolver: 'read-anything' })).toBe(false);
expect(withDetect({})).toBe(false);
expect(withDetect({ configResolver: 'deepseek-route', screenLines: 2 })).toBe(false);
});
it('countCaptureGroups counts named groups and ignores non-capturing ones', () => {
expect(countCaptureGroups('a(?:b)(?<m>c)')).toBe(1);
expect(countCaptureGroups('(a)(b)')).toBe(2);
expect(countCaptureGroups('abc')).toBe(0);
expect(countCaptureGroups('(')).toBe(-1);
});
});
/**
* The foot of a pi pane (live pi 1.1.0 capture, rules shortened): transcript, the composer
* between two `─` rules, the cwd row, then the stats row with the model on the right.
*/
function piPane(statsRow: string | null, transcript: string[] = [], below: string[] = []): string {
const rule = '─'.repeat(60);
return [
' Pi can explain its own features and look up its docs.',
...transcript,
rule,
'',
rule,
'~/codeman-cases/testcase',
...(statsRow === null ? [] : [statsRow]),
...below,
'',
].join('\n');
}
const PI_LIVE = piPane('0.8%/253k (auto)' + ' '.repeat(40) + 'qwen3.8-27b-pi • xhigh');
describe('readScreenModel', () => {
it("reads dsh's model off the row under its composer", () => {
expect(readScreenModel(DSH_LIVE, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
// A footer with the model alone, and one with a row below it (the activity line).
expect(readScreenModel(dshPane(' deepseek-v4-flash'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash');
expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch') + ' ⠋ Thinking… 3s\n', DSH, DSH_ROWS)).toBe(
'qwen3.8-27b'
);
});
it("never reads the field after a switched-off model as the model (dsh's effort, mode, folder)", () => {
// dsh-TUI with `statusBar.model: false` (live capture: ` medium · th-config`): the
// effort id comes first, then the mode, then the folder name.
const at = (cwdBasename: string) => ({ rejectWords: DSH_REJECT, cwdBasename });
for (const effort of ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) {
expect(readScreenModel(dshPane(` ${effort} · x`), DSH, DSH_ROWS, at('x')), effort).toBeUndefined();
}
expect(readScreenModel(dshPane(' default · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
// The words compare ignoring case (the banner capitalizes effort; the footer does not).
expect(readScreenModel(dshPane(' Medium · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
// A mode's drawn label is two words: never one field the pattern takes.
expect(readScreenModel(dshPane(' plan mode · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
// The session's own folder name first, with and without a digit.
expect(readScreenModel(dshPane(' th-config'), DSH, DSH_ROWS, at('th-config'))).toBeUndefined();
expect(readScreenModel(dshPane(' project2 · main'), DSH, DSH_ROWS, at('project2'))).toBeUndefined();
// A field that is none of those IS read: the same `project2` in another folder.
expect(readScreenModel(dshPane(' project2'), DSH, DSH_ROWS, at('elsewhere'))).toBe('project2');
});
it('reads the official DeepSeek ids, which carry no digit, with the model field on', () => {
const at = { rejectWords: DSH_REJECT, cwdBasename: 'th-config' };
expect(readScreenModel(dshPane(' deepseek-chat · max · th-config'), DSH, DSH_ROWS, at)).toBe('deepseek-chat');
expect(readScreenModel(dshPane(' deepseek-reasoner · high · th-config'), DSH, DSH_ROWS, at)).toBe(
'deepseek-reasoner'
);
// The live qwen footer still reads.
expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch'), DSH, DSH_ROWS, at)).toBe('qwen3.8-27b');
});
it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => {
expect(readScreenModel(CODEX_LIVE, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
expect(readScreenModel(CODEX_154, CODEX, CODEX_ROWS)).toBe('gpt-5.6-sol');
});
it("reads codex's model above the 0.162.0 hint row, and with the hint gone while typing", () => {
expect(readScreenModel(CODEX_162, CODEX, CODEX_ROWS)).toBe('GPT-6-Luna');
const shortHint = codexPane(' GPT-6-Luna default · ~/codeman-cases/testcase', [], ' ? for shortcuts');
expect(readScreenModel(shortHint, CODEX, CODEX_ROWS)).toBe('GPT-6-Luna');
// Typing hides the hint, which puts the footer back on the last row.
const typing = codexPane(' GPT-6-Luna default · ~/codeman-cases/testcase');
expect(readScreenModel(typing, CODEX, CODEX_ROWS)).toBe('GPT-6-Luna');
// Neither hint row is ever read as a model.
expect(readScreenModel(codexPane(null, [], ' ← for agents · ? for shortcuts'), CODEX, CODEX_ROWS)).toBeUndefined();
});
it("reads codex's model at every reasoning effort Codeman can launch it with, ultra included", () => {
// `ultra` is offered by the codexReasoningEffort App Setting and codex's own /model
// picker; a hand-kept effort list in the pattern once left it out, so an ultra
// session's header never named its model. The footer prints the lowercase level.
const ultra = codexPane(' GPT-6-Astra ultra · ~/codeman-cases/testcase', [], ' ? for shortcuts');
expect(readScreenModel(ultra, CODEX, CODEX_ROWS)).toBe('GPT-6-Astra');
for (const effort of [...CODEX_REASONING_EFFORTS, 'default']) {
const atRest = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`, [], ' ? for shortcuts');
expect(readScreenModel(atRest, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
const typing = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`);
expect(readScreenModel(typing, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
}
});
it('never takes a transcript line shaped like the footer', () => {
// The agent printed a line exactly like each CLI's footer, and the real footer is
// hidden (a dsh status bar switched off; a codex popup over its last row). The
// transcript sits above the composer, so neither may be read as the model.
const dshForged = dshPane(null, [DSH_BORDER_BOTTOM, ' evil-model · medium · th-scratch']);
expect(readScreenModel(dshForged, DSH, DSH_ROWS)).toBeUndefined();
const codexForged = codexPane(null, [' evil-model high · ~/codeman-cases/th-scratch']);
// A codex screen whose last row is the composer: nothing to read.
expect(readScreenModel(codexForged, CODEX, CODEX_ROWS)).toBeUndefined();
// With the real footer back, the real model wins over the forged line above it.
const dshBoth = dshPane(' qwen3.8-27b · medium · th-scratch', [
DSH_BORDER_BOTTOM,
' evil-model · medium · th-scratch',
]);
expect(readScreenModel(dshBoth, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
const codexBoth = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch', [
' evil-model high · ~/codeman-cases/th-scratch',
]);
expect(readScreenModel(codexBoth, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
// The two-row window must not open the door either: a forged line followed by an
// indented transcript row still has the `›` composer under it, so it is never read.
const codexForgedPair = codexPane(null, [' evil-model high · ~/codeman-cases/th-scratch', ' ? for shortcuts']);
expect(readScreenModel(codexForgedPair, CODEX, CODEX_ROWS)).toBeUndefined();
const codexBoth162 = codexPane(
' GPT-6-Luna default · ~/codeman-cases/testcase',
[' evil-model high · ~/codeman-cases/th-scratch'],
' ? for shortcuts'
);
expect(readScreenModel(codexBoth162, CODEX, CODEX_ROWS)).toBe('GPT-6-Luna');
});
it('does not read a popup under the composer as a model', () => {
const slash = dshPane(' /model Switch the model route');
expect(readScreenModel(slash, DSH, DSH_ROWS)).toBeUndefined();
const codexSlash = codexPane(' /model choose what model and reasoning effort to use');
expect(readScreenModel(codexSlash, CODEX, CODEX_ROWS)).toBeUndefined();
// A last row without codex's `<model> <effort> ·` shape names no model, even one
// whose first word could pass for a model id.
const codexNoModel = codexPane(' default · ~/codeman-cases/th-scratch');
expect(readScreenModel(codexNoModel, CODEX, CODEX_ROWS)).toBeUndefined();
});
it("reads pi's model off its footer stats row, in every shape pi's footer code draws", () => {
const read = (row: string, below: string[] = []) =>
readScreenModel(piPane(row, [], below), PI, PI_ROWS, { rejectWords: PI_REJECT, cwdBasename: 'testcase' });
expect(readScreenModel(PI_LIVE, PI, PI_ROWS)).toBe('qwen3.8-27b-pi');
// More than one provider configured: `(provider)` in front of the model.
expect(read('↑12k ↓3.4k $0.123 12.3%/200k (auto) (anthropic) claude-sonnet-4-5 • medium')).toBe(
'claude-sonnet-4-5'
);
expect(read('?/128k gpt-5 • thinking off')).toBe('gpt-5');
// A routed model: the selected one is named first.
expect(read('3.0%/1.0M (auto) auto • high → qwen/qwen3-coder • low')).toBe('auto');
// A model without reasoning ends the row.
expect(read('0.8%/253k (auto) llama3.3:70b')).toBe('llama3.3:70b');
// An extension's status row under the stats row.
expect(read('0.8%/253k (auto) • xp qwen3.8-27b-pi • xhigh', ['my-ext: ready'])).toBe('qwen3.8-27b-pi');
// pi's placeholder when no model is selected.
expect(read('0.8%/253k (auto) no-model')).toBeUndefined();
});
it('never reads a model pi truncated to fit a narrow pane', () => {
// pi cuts the right side with no ellipsis and leaves exactly two spaces in front.
const read = (row: string) => readScreenModel(piPane(row), PI, PI_ROWS);
expect(read('↑12k ↓3.4k R45k $0.123 12.3%/253k (auto) qwen3.8-27b-pi •')).toBe('qwen3.8-27b-pi');
expect(read('↑12k ↓3.4k R45k $0.123 12.3%/253k (auto) qwen3.8-2')).toBeUndefined();
expect(read('0.8%/253k (auto) llama3.3:70')).toBeUndefined();
});
it('never takes a pi transcript line shaped like the footer', () => {
const forged = piPane(null, ['0.8%/253k (auto) evil-model • high']);
expect(readScreenModel(forged, PI, PI_ROWS)).toBeUndefined();
});
it('says nothing about an empty or unreadable frame', () => {
expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined();
expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined();
});
});
/**
* opencode 1.3.0's composer agent row, verbatim (trailing blanks trimmed, bars shortened):
* the owner's home screen on the 1.36.0 beta, one with a tip, an in-session pane at 200
* columns whose sidebar shares the row, and a 40-column pane mid-turn.
*/
const OC_EDGE = '╹' + '▀'.repeat(56);
const OC_HOME = [
' ┃',
' ┃ Ask anything... "Fix a TODO in the codebase"',
' ┃',
' ┃ Build Big Pickle OpenCode Zen',
` ${OC_EDGE}`,
' ctrl+t variants tab agents ctrl+p commands',
' ~/codeman-cases/testcase 1.3.0',
'',
].join('\n');
const OC_HOME_TIP = [
' ┃ Ask anything... "Fix a TODO in the codebase"',
' ┃',
' ┃ Build Big Pickle OpenCode Zen',
` ${OC_EDGE}`,
' ctrl+t variants tab agents ctrl+p commands',
' ● Tip Use {env:VAR_NAME} syntax to reference environment variables in',
' /tmp/claude-1000/-home-arkon-default-claudeman/6dec47be/scratchpad/oc-repro 1.3.0',
'',
].join('\n');
const OC_WIDE = [
' ▣ Build · qwen3.8-27b · 11.9s' + ' '.repeat(40) + '[•] summarize',
' ┃' + ' '.repeat(157) + '/tmp/claude-1000/-home-arkon-default-',
' ┃' + ' '.repeat(157) + 'claudeman/6dec47be-0153-4e13-a229-',
' ┃ Build qwen3.8-27b Qwen 5090' + ' '.repeat(127) + 'fc335c2c76c8/scratchpad/oc-turn',
` ${OC_EDGE}`,
' '.repeat(129) + 'tab agents ctrl+p commands • OpenCode 1.3.0',
'',
].join('\n');
const OC_PHONE_MID = [
' ┃',
' ┃ Build qwen3.8-27b Qwen 5090',
' ╹' + '▀'.repeat(35),
' ⬝⬝⬝⬝⬝⬝⬝⬝ esc tab ctrl+p',
' interragents commands',
' upt',
'',
].join('\n');
const OC = compileVersionRegex(detectOf('opencode').screenLine)!;
const OC_ROWS = detectOf('opencode').screenLines;
describe("opencode's composer agent row", () => {
it('compiles through compileVersionRegex() with exactly one capture group', () => {
expect(OC).not.toBeNull();
expect(countCaptureGroups(detectOf('opencode').screenLine!)).toBe(1);
});
it('reads the model and provider opencode shows, at every width and on the home screen', () => {
expect(readScreenModel(OC_HOME, OC, OC_ROWS)).toBe('Big Pickle OpenCode Zen');
expect(readScreenModel(OC_HOME_TIP, OC, OC_ROWS)).toBe('Big Pickle OpenCode Zen');
// The 200-column sidebar shares the row after a run of spaces, and stays out of it.
expect(readScreenModel(OC_WIDE, OC, OC_ROWS)).toBe('qwen3.8-27b Qwen 5090');
expect(readScreenModel(OC_PHONE_MID, OC, OC_ROWS)).toBe('qwen3.8-27b Qwen 5090');
});
it('keeps the variant opencode shows after the provider', () => {
const variant = OC_HOME.replace('Big Pickle OpenCode Zen', 'Claude Sonnet 4.5 Anthropic · high');
expect(readScreenModel(variant, OC, OC_ROWS)).toBe('Claude Sonnet 4.5 Anthropic · high');
});
it('says nothing where the row is hidden or holds no model', () => {
// A permission prompt replaces the composer: no agent row above a `╹` edge.
const permission = [
' ┃ △ Permission required',
' ┃ $ echo permission-check',
' ┃ Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm',
' ┃',
'',
].join('\n');
expect(readScreenModel(permission, OC, OC_ROWS)).toBeUndefined();
// Shell mode draws `Shell` and no model; before a provider is connected, a placeholder.
expect(readScreenModel(OC_HOME.replace('Build Big Pickle OpenCode Zen', 'Shell'), OC, OC_ROWS)).toBeUndefined();
const none = OC_HOME.replace('Big Pickle OpenCode Zen', 'No provider selected Connect a provider');
expect(readScreenModel(none, OC, OC_ROWS)).toBeUndefined();
});
it('never takes a row the agent printed above the composer', () => {
// An answer that draws a composer-shaped row and an edge under it: the real composer
// is the LAST such pair, and only opencode's own chrome sits below that.
const forged = [
' ┃ Build evil-model Evil',
` ${OC_EDGE}`,
' ┃',
' ┃ Build Big Pickle OpenCode Zen',
` ${OC_EDGE}`,
' ctrl+t variants tab agents ctrl+p commands',
' ~/codeman-cases/testcase 1.3.0',
'',
].join('\n');
// Both pairs sit inside the window, so this is the lookahead's doing, not the window's.
expect(forged.split('\n').filter(Boolean).length).toBeLessThanOrEqual(OC_ROWS!);
expect(readScreenModel(forged, OC, OC_ROWS)).toBe('Big Pickle OpenCode Zen');
});
it('publishes the model through a session once the idle check reads the pane', () => {
vi.useFakeTimers();
try {
const mux = { isAvailable: () => true, capturePaneText: () => OC_HOME } as unknown as NonNullable<
ConstructorParameters<typeof Session>[0]
>['mux'];
const session = new Session({
workingDir: '/tmp',
mode: 'opencode',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
const internals = session as unknown as {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
const frame = '\x1b[37;3H┃ Build Big Pickle OpenCode Zen';
internals._handleTerminalOutput(frame);
internals._detectInteractiveActivity(frame);
vi.advanceTimersByTime(IDLE_SILENCE_MS + 3000);
expect(session.toState().displayModel).toEqual({ model: 'Big Pickle OpenCode Zen', source: 'screen' });
} finally {
vi.useRealTimers();
}
});
});
describe('sanitizeModelName', () => {
it('keeps markup as text: sanitizing is not escaping, the browser renders text', () => {
expect(sanitizeModelName('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>');
});
it('drops escape sequences and control characters, and collapses whitespace', () => {
expect(sanitizeModelName('\x1b[31mOpus\x1b[0m\t 4.8‮\n')).toBe('Opus 4.8');
expect(sanitizeModelName('\x00\x07')).toBeUndefined();
expect(sanitizeModelName(' ')).toBeUndefined();
expect(sanitizeModelName(42)).toBeUndefined();
});
it(`caps the name at ${MAX_DISPLAY_MODEL_CHARS} characters`, () => {
expect(sanitizeModelName('m'.repeat(500))).toHaveLength(MAX_DISPLAY_MODEL_CHARS);
});
});
describe('resolveDisplayModel', () => {
const reported = { model: 'Sonnet 4.6', source: 'statusline' as const };
it('the custom endpoint wins, then the newest report, then the launch model', () => {
expect(resolveDisplayModel({ customModelId: 'qwen3.8-27b', reported, launchModel: 'opus' })).toEqual({
model: 'qwen3.8-27b',
source: 'custom-endpoint',
});
expect(resolveDisplayModel({ reported, launchModel: 'opus' })).toEqual(reported);
expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' });
});
it("the config ranks below the CLI's own report and above the launch model", () => {
expect(resolveDisplayModel({ reported, configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual(reported);
expect(resolveDisplayModel({ configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual({
model: 'qwen3.8-27b',
source: 'config',
});
expect(resolveDisplayModel({ customModelId: 'm', configModel: 'qwen3.8-27b' })?.source).toBe('custom-endpoint');
expect(resolveDisplayModel({ configModel: null, launchModel: 'opus' })?.source).toBe('launch');
});
it('knows nothing when nothing is known: no placeholder', () => {
expect(resolveDisplayModel({})).toBeUndefined();
expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined();
});
});
describe('restoredReportedModel', () => {
it('restores what the CLI reported, never a derived answer', () => {
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'screen' })).toEqual({
model: 'qwen3.8-27b',
source: 'screen',
});
expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline');
expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined();
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'config' })).toBeUndefined();
expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined();
expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined();
expect(restoredReportedModel('screen')).toBeUndefined();
expect(restoredReportedModel(undefined)).toBeUndefined();
});
});
describe('launchModelFor', () => {
it("reads the model param where each CLI's registry entry keeps it", () => {
expect(launchModelFor('claude', { model: 'haiku' })).toBe('haiku');
expect(launchModelFor('codex', { codexConfig: { model: 'gpt-5.5' } })).toBe('gpt-5.5');
expect(launchModelFor('grok', { grokConfig: { model: 'grok-code-fast' } })).toBe('grok-code-fast');
// Another CLI's config, or a top-level model, is not this CLI's launch model.
expect(launchModelFor('codex', { model: 'opus', grokConfig: { model: 'x' } })).toBeUndefined();
});
it('has none for a CLI whose model is not a launch param', () => {
expect(launchModelFor('deepseek', { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } })).toBeUndefined();
expect(launchModelFor('shell', { model: 'opus' })).toBeUndefined();
expect(launchModelFor('no-such-cli', { model: 'opus' })).toBeUndefined();
});
});
describe('a session', () => {
afterEach(() => {
vi.useRealTimers();
});
type Internals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
_customModel?: { endpointId: string; modelId: string; envKeys: string[] };
};
function withFakePane(mode: string, screen: () => string | null, extra: Record<string, unknown> = {}): Session {
const mux = { isAvailable: () => true, capturePaneText: () => screen() } as unknown as NonNullable<
ConstructorParameters<typeof Session>[0]
>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
...extra,
} as ConstructorParameters<typeof Session>[0]);
}
/** A composer repaint (arms the idle confirmation), then quiet: the probe reads the screen. */
function settle(session: Session, glyph: string): void {
const internals = session as unknown as Internals;
for (let i = 0; i < 3; i++) {
const frame = `\x1b[31;1H${glyph}\xa0`;
internals._handleTerminalOutput(frame);
internals._detectInteractiveActivity(frame);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
it('publishes the model its footer names, and follows the footer when it changes', () => {
vi.useFakeTimers();
let screen = DSH_LIVE;
const session = withFakePane('deepseek', () => screen);
const changed = vi.fn();
session.on('displayModelChanged', changed);
expect(session.toState().displayModel).toBeUndefined();
settle(session, '❯');
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
expect(changed).toHaveBeenCalledTimes(1);
// The same footer again: nothing new to say.
settle(session, '❯');
expect(changed).toHaveBeenCalledTimes(1);
// An in-session switch redraws the footer.
screen = dshPane(' deepseek-v4-flash · high · th-scratch');
settle(session, '❯');
expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
expect(changed).toHaveBeenCalledTimes(2);
});
it('publishes the model a pi footer names once the pane settles', () => {
vi.useFakeTimers();
const session = withFakePane('pi', () => PI_LIVE);
expect(session.toState().displayModel).toBeUndefined();
// pi's composer rule is the glyph that arms the confirmation, whose probe reads the footer.
settle(session, '─');
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b-pi', source: 'screen' });
});
it("a footer field equal to the session's folder is not its model; the official ids are", () => {
vi.useFakeTimers();
let screen = dshPane(' th-config');
const session = withFakePane('deepseek', () => screen, { workingDir: '/w/th-config' });
settle(session, '❯');
expect(session.toState().displayModel).toBeUndefined();
screen = dshPane(' medium · th-config');
settle(session, '❯');
expect(session.toState().displayModel).toBeUndefined();
screen = dshPane(' deepseek-chat · max · th-config');
settle(session, '❯');
expect(session.toState().displayModel).toEqual({ model: 'deepseek-chat', source: 'screen' });
});
it('keeps the last model when the footer cannot be read', () => {
vi.useFakeTimers();
let screen: string | null = CODEX_LIVE;
const session = withFakePane('codex', () => screen);
settle(session, '›');
expect(session.toState().displayModel?.model).toBe('gpt-5.6-terra');
screen = codexPane(' /model choose what model and reasoning effort to use');
settle(session, '›');
screen = null;
settle(session, '›');
expect(session.toState().displayModel).toEqual({ model: 'gpt-5.6-terra', source: 'screen' });
});
it('a CLI without a footer pattern never reads one (a claude pane says nothing)', () => {
vi.useFakeTimers();
const session = withFakePane('claude', () => '❯ \n Haiku 4.5 in:0 out:0\n');
settle(session, '❯');
expect(session.toState().displayModel).toBeUndefined();
});
it('shows the launch model until the CLI reports, and a custom endpoint over both', () => {
const session = withFakePane('claude', () => null, { model: 'haiku' });
expect(session.toState().displayModel).toEqual({ model: 'haiku', source: 'launch' });
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(true);
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(false);
expect(session.noteReportedModel('statusline', '')).toBe(false);
(session as unknown as Internals)._customModel = { endpointId: 'e', modelId: 'qwen3.8-27b', envKeys: [] };
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'custom-endpoint' });
(session as unknown as Internals)._customModel = undefined;
expect(session.toState().displayModel?.model).toBe('Haiku 4.5');
});
it("an external CLI's launch model is its own config's, never the inert top-level one", () => {
const codex = withFakePane('codex', () => null, { model: 'opus', codexConfig: { model: 'gpt-5.5' } });
expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' });
const dsh = withFakePane('deepseek', () => null, { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } });
expect(dsh.toState().displayModel).toBeUndefined();
});
it('restores a reported model after a restart, until the next report replaces it', () => {
const session = withFakePane('deepseek', () => null, {
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
});
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
session.noteReportedModel('screen', 'deepseek-v4-flash');
expect(session.toState().displayModel?.model).toBe('deepseek-v4-flash');
// A launch answer from the previous run is derived again, not restored.
const claude = withFakePane('claude', () => null, { displayModel: { model: 'opus', source: 'launch' } });
expect(claude.toState().displayModel).toBeUndefined();
});
});
describe('a dsh session over a fixture dsh home (end to end, no mocks)', () => {
it('names the route its profile pins until the screen names one, then the screen', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-e2e-'));
try {
const home = join(root, 'dsh');
mkdirSync(join(home, 'profiles', 'dsh-tui'), { recursive: true });
writeFileSync(
join(home, 'profiles', 'dsh-tui', 'package.json'),
JSON.stringify({ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui'] } } })
);
writeFileSync(
join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'),
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n'
);
const session = new Session({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { profile: 'dsh-tui' },
envOverrides: { DSH_HOME: home },
} as ConstructorParameters<typeof Session>[0]);
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {}
);
await vi.waitFor(() =>
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' })
);
session.noteReportedModel('screen', 'deepseek-v4-flash');
expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});