mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
- 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>
690 lines
34 KiB
TypeScript
690 lines
34 KiB
TypeScript
/**
|
||
* 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 });
|
||
}
|
||
});
|
||
});
|