mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
The digit rule from 21ae48a5 hid the official DeepSeek ids (`deepseek-chat`,
`deepseek-reasoner` carry no digit), so a session on the official route with
the model field on showed the logo alone (its bundle row pins a provider
alone, so the config had nothing either). It also still misread a folder name
with a digit when every field before it was off.
Now the captured field is rejected when it is what the field can be when it is
NOT the model, and read otherwise:
- capabilities.modelDetect.rejectWords (registry data, single tokens, compared
ignoring case; the schema bounds them and requires a screenLine). dsh lists
every effort id its adapters offer (pi-ai THINKING_LEVELS plus the DeepSeek
adapter's off/low/high/max) and the shipped mode ids, from dsh 0.1.1-rc.2 /
dsh-TUI 0.10.0-beta.1. A mode's drawn label (`plan mode`, `full access`,
CJK) can never be one captured field.
- In the shared screen reader, for every CLI: a field equal to the session's
own working-directory basename is the folder, never the model.
Fixtures: `deepseek-chat` and `deepseek-reasoner` with the model field on are
read; every effort id, `default`, `plan mode`, and the folder name first (with
and without a digit) are not; the live qwen footer still reads `qwen3.8-27b`.
Known gaps, all off by default, are named in stock.ts: a custom mode id drawn
raw, a git branch or a one-word session title first, and the non-compact
footer layout (nothing read there; the route config applies).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
455 lines
23 KiB
TypeScript
455 lines
23 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.
|
||
*/
|
||
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';
|
||
|
||
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 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 on the last row. */
|
||
function codexPane(statusLine: string | null, transcript: string[] = []): 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]),
|
||
'',
|
||
].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'
|
||
);
|
||
|
||
describe('the registry patterns', () => {
|
||
it('compile through compileVersionRegex() with exactly one capture group', () => {
|
||
for (const mode of ['deepseek', 'codex']) {
|
||
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);
|
||
});
|
||
});
|
||
|
||
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('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');
|
||
});
|
||
|
||
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('says nothing about an empty or unreadable frame', () => {
|
||
expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined();
|
||
expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
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("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 });
|
||
}
|
||
});
|
||
});
|