From 661fe3dc139622e73091b26abc6bdcd8be636784 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 19:41:56 +0200 Subject: [PATCH] feat(sessions): a dsh session shows its route config's model while its screen names none displayModel gains a `config` source, ranked below any report from the running CLI and above the launch model: custom endpoint, then statusline or screen, then config, then launch, then nothing. The screen still wins whenever it names a model, since that is what the running TUI uses. - Registry data: capabilities.modelDetect gains `configResolver`, a NAMED reader (src/model-config-resolvers.ts), like a launcher profile; dsh names 'deepseek-route' (the reader from the previous commit). `screenLine` becomes optional; the schema refuses a modelDetect naming nothing, an unknown reader, or screenLines without a screenLine. - Session: the reader runs from _withPaneLifecycle's finally, so at every pane start, attach and relaunch, with the session's own launch config (legacyConfigForMode) and env (its clamped overrides, then the server's), so a per-session DSH_HOME is the home read. Async; a read that lands after a newer one or after the session stopped is dropped; a remote or docker session reads nothing locally. A change emits displayModelChanged (broadcast and persist). Not restored after a restart: the next attach reads it again, and a restored screen value outranks it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/config/cli-registry/schema.ts | 16 ++- src/config/cli-registry/stock.ts | 3 + src/config/cli-registry/types.ts | 12 +- src/model-config-resolvers.ts | 49 ++++++++ src/session-display-model.ts | 10 +- src/session.ts | 84 +++++++++++--- src/types/session.ts | 7 +- test/session-config-model.test.ts | 180 +++++++++++++++++++++++++++++ test/session-display-model.test.ts | 63 ++++++++++ 9 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 src/model-config-resolvers.ts create mode 100644 test/session-config-model.test.ts diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index c58247f3..c8f11b17 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -15,7 +15,7 @@ import { z } from 'zod'; import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; -import type { McpConfigFormat } from './types.js'; +import type { McpConfigFormat, ModelConfigResolverName } from './types.js'; /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ const cliId = z @@ -382,12 +382,24 @@ const capabilitiesSchema = z .refine( (src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1, 'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group' - ), + ) + .optional(), // Bounded hard, like watchingLines: every row it adds is one more row the agent // itself may be able to write. screenLines: z.number().int().min(1).max(4).optional(), + // A NAMED reader (src/model-config-resolvers.ts), never code in config. + configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(), }) .strict() + // Typos rather than configurations, refused at LOAD time like watchingLines. + .refine( + (v) => v.screenLine !== undefined || v.configResolver !== undefined, + 'modelDetect declares nothing to read' + ) + .refine( + (v) => v.screenLines === undefined || v.screenLine !== undefined, + 'screenLines has nothing to bound without a screenLine' + ) .optional(), privilegedParams: z .array( diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 30915aae..caa73a03 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1243,6 +1243,9 @@ const DEEPSEEK: CliEntry = { modelDetect: { screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, screenLines: 3, + // With the status bar's model field off (or before it paints), the route the + // session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts. + configResolver: 'deepseek-route', }, // Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the // launcher's own default, `workspace-write`, which already asks. Clamping to diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 4de6936c..634dda72 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -93,6 +93,9 @@ export interface CliVariant { /** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */ export type NewlineSequence = 'line-feed' | 'esc-enter'; +/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */ +export type ModelConfigResolverName = 'deepseek-route'; + /** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */ export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; @@ -477,8 +480,15 @@ export interface CliCapabilities { * own footer format), never on a shape the agent could print in its transcript. Measured * on a live pane per CLI; absent means the CLI's screen is never read for a model and * the session shows its launch model, if any. + * + * `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the + * model the CLI's own config pins, the way that CLI resolves it for the session, for + * while the screen names none (its status line switched off, or not drawn yet). Read + * once per pane start, attach or relaunch, bounded and read-only; the screen still + * wins whenever it names a model. A NAMED reader, like a launcher profile, so the + * per-CLI behaviour stays data here and code in one module. */ - modelDetect?: { screenLine: string; screenLines?: number }; + modelDetect?: { screenLine?: string; screenLines?: number; configResolver?: ModelConfigResolverName }; /** * Params a non-granted multi-user owner may not set freely, and what they are forced to. * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. diff --git a/src/model-config-resolvers.ts b/src/model-config-resolvers.ts new file mode 100644 index 00000000..9bdfe53b --- /dev/null +++ b/src/model-config-resolvers.ts @@ -0,0 +1,49 @@ +/** + * @fileoverview The config readers a CLI's registry entry may name for the model its + * session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives + * here, keyed by name, so no code branches on a CLI id (like the launcher profiles in + * config/cli-registry/profiles.ts). + * + * A reader answers the model the CLI's own config pins for one session, or null when + * it pins none or the answer is in any doubt. It must be read-only, bounded (no + * synchronous filesystem call, nothing that can wait on a dead mount) and must return + * the model id alone, never another config value. + * + * @module model-config-resolvers + */ + +import type { ModelConfigResolverName } from './config/cli-registry/types.js'; +import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js'; + +/** What a reader gets to know about the session. */ +export interface ModelConfigContext { + /** The session's own launch config for its CLI (its `Config`), if any. */ + config: Record | undefined; + /** The environment the session's CLI runs with (its own overrides, then the server's). */ + env: (key: string) => string | undefined; +} + +const RESOLVERS: Record Promise> = { + // dsh-TUI's route: the session's profile (else the one the launch boots, which the + // launch names from the server's own dsh home) read under the session's dsh home. + 'deepseek-route': (ctx) => + readDeepSeekRouteModel({ + profile: ctx.config?.profile, + home: effectiveDshHome(ctx.env), + serverHome: effectiveDshHome((key) => process.env[key]), + }), +}; + +/** + * The model the named reader resolves for a session, or null. + * + * @param name a `configResolver` from the registry (schema-checked at load) + * @param ctx what the reader may know about the session + */ +export async function resolveConfigModel( + name: ModelConfigResolverName, + ctx: ModelConfigContext +): Promise { + const resolver = RESOLVERS[name]; + return resolver ? resolver(ctx) : null; +} diff --git a/src/session-display-model.ts b/src/session-display-model.ts index 9eae26cb..c9e59d6c 100644 --- a/src/session-display-model.ts +++ b/src/session-display-model.ts @@ -13,7 +13,11 @@ * in-session `/model`); a CLI whose registry entry declares * `capabilities.modelDetect` has its footer read off the pane capture the idle/working * probe already takes. - * 3. **launch**: the model the session was launched with (claude's `--model` or the + * 3. **config**: the model the CLI's own config pins for this session, read by the + * reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the + * dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not + * a report from the running CLI, so any report outranks it. + * 4. **launch**: the model the session was launched with (claude's `--model` or the * app-wide default it was created with; another CLI's `Config.model`). What was * asked for, not what was reported, so it only shows when nothing reported. * @@ -130,17 +134,21 @@ export function restoredReportedModel(saved: unknown): ReportedModel | undefined * * @param input.customModelId the custom endpoint's model, when the session is pointed at one * @param input.reported the newest report from the CLI itself + * @param input.configModel the model the CLI's config pins for the session * @param input.launchModel the model the session was launched with */ export function resolveDisplayModel(input: { customModelId?: string; reported?: ReportedModel | null; + configModel?: string | null; launchModel?: string; }): DisplayModel | undefined { const custom = sanitizeModelName(input.customModelId); if (custom) return { model: custom, source: 'custom-endpoint' }; const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined; if (reported && input.reported) return { model: reported, source: input.reported.source }; + const config = sanitizeModelName(input.configModel); + if (config) return { model: config, source: 'config' }; const launch = sanitizeModelName(input.launchModel); if (launch) return { model: launch, source: 'launch' }; return undefined; diff --git a/src/session.ts b/src/session.ts index 15811f1d..f4a93fe1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -138,6 +138,8 @@ import { upsertAttachmentHistory as upsertAttachmentHistoryList, } from './session-attachment-history.js'; import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js'; +import { resolveConfigModel } from './model-config-resolvers.js'; +import { legacyConfigForMode } from './session-cli-registry-bridge.js'; import { launchModelFor, readScreenModel, @@ -566,6 +568,14 @@ export class Session extends EventEmitter { * restart, so an idle session keeps naming its model until the next report. */ private _reportedModel: ReportedModel | null = null; + /** + * The model the CLI's own config pins for this session (`modelDetect.configResolver`), + * read at each pane start, attach or relaunch; null when it pins none. Below any + * report from the running CLI in `displayModel`. Not persisted: the next start reads it. + */ + private _configModel: string | null = null; + /** Bumped per config read, so a read that lands after a newer one is dropped. */ + private _configModelGen = 0; /** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */ private _modelLineRe: RegExp | null | undefined = undefined; /** Resolved with the pattern above: how many rows at the foot of the screen it sees. */ @@ -1290,6 +1300,9 @@ export class Session extends EventEmitter { } finally { this._paneLifecycleOps--; this._paneStartedAt = Date.now(); + // A start, attach or relaunch is when the CLI read its config, so it is when + // the model that config pins is read here too. + this._refreshConfigModel(); } } @@ -3187,7 +3200,7 @@ export class Session extends EventEmitter { private _modelLinePattern(): RegExp | null { if (this._modelLineRe === undefined) { const detect = getCli(this.mode)?.capabilities.modelDetect; - this._modelLineRe = detect ? compileVersionRegex(detect.screenLine) : null; + this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null; this._modelLineRows = detect?.screenLines ?? 1; } return this._modelLineRe; @@ -3219,24 +3232,63 @@ export class Session extends EventEmitter { return resolveDisplayModel({ customModelId: this._customModel?.modelId, reported: this._reportedModel, - // The same option bag the spawn reads its launch params from: `model` at the top - // for claude (the `--model` or app-wide default it was created with; inert for - // every other CLI, which is why it is not handed over for them), each other CLI's - // own `Config`. Where the model param lives is registry data. - launchModel: launchModelFor(this.mode, { - model: cliTakesSessionModel(this.mode) ? this._model : undefined, - openCodeConfig: this._openCodeConfig, - codexConfig: this._codexConfig, - geminiConfig: this._geminiConfig, - antigravityConfig: this._antigravityConfig, - piConfig: this._piConfig, - grokConfig: this._grokConfig, - deepSeekConfig: this._deepSeekConfig, - ompConfig: this._ompConfig, - }), + configModel: this._configModel, + launchModel: launchModelFor(this.mode, this._launchOptionBag()), }); } + /** + * The same option bag the spawn reads its launch params from: `model` at the top for + * claude (the `--model` or app-wide default it was created with; inert for every other + * CLI, which is why it is not handed over for them), each other CLI's own + * `Config`. Where a param lives is registry data (`legacyConfigForMode`). + */ + private _launchOptionBag(): Record { + return { + model: cliTakesSessionModel(this.mode) ? this._model : undefined, + openCodeConfig: this._openCodeConfig, + codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, + antigravityConfig: this._antigravityConfig, + piConfig: this._piConfig, + grokConfig: this._grokConfig, + deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, + }; + } + + /** + * Read the model this session's CLI config pins, with the reader its registry entry + * names (`capabilities.modelDetect.configResolver`), and announce a change. Async and + * bounded (the reader probes before it reads); a read that lands after a newer one, + * or after the session stopped, is dropped. A remote or docker session's CLI reads its + * config on another machine or in its container, so nothing local is read for it. + */ + private _refreshConfigModel(): void { + const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver; + if (!name || this._remote || this._docker) return; + const gen = ++this._configModelGen; + const overrides = this._envOverrides; + resolveConfigModel(name, { + config: legacyConfigForMode(this.mode, this._launchOptionBag()), + // The session's own env first (already clamped for a non-granted owner), then the + // server's: what the pane's CLI inherits. + env: (key) => overrides?.[key] ?? process.env[key], + }).then( + (model) => { + if (gen !== this._configModelGen || this._isStopped) return; + // Sanitized where it is published (resolveDisplayModel), like every source. + const next = model || null; + if (next === this._configModel) return; + this._configModel = next; + this.emit('displayModelChanged'); + }, + () => { + /* A reader answers null on doubt and never throws; a throw changes nothing. */ + } + ); + } + /** * Read the background-work chip off the same capture the working probe just took. * diff --git a/src/types/session.ts b/src/types/session.ts index 2e7d4415..b0f4214d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -646,9 +646,11 @@ export interface CustomModelSelection { * - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins. * - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch. * - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch. + * - `config`: what the CLI's own config pins for this session + * (`capabilities.modelDetect.configResolver`), while its screen names none. * - `launch`: what the session was launched with; nothing has reported since. */ -export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'launch'; +export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch'; /** The model a session runs as far as the server knows, for a session header. */ export interface DisplayModel { @@ -866,7 +868,8 @@ export interface SessionState { /** * The model this session runs, as far as the server knows it, and where that came from * (src/session-display-model.ts): the custom endpoint's model, else the newest report - * from the CLI itself (statusline or its own footer), else the launch model. Absent when + * from the CLI itself (statusline or its own footer), else the model its config pins, + * else the launch model. Absent when * none is known; a session header then shows the harness alone. Untrusted display text * (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen` * value is restored after a restart until the next report replaces it. diff --git a/test/session-config-model.test.ts b/test/session-config-model.test.ts new file mode 100644 index 00000000..78159340 --- /dev/null +++ b/test/session-config-model.test.ts @@ -0,0 +1,180 @@ +/** + * The config source of a session's `displayModel`: the model its CLI's own config pins + * (`capabilities.modelDetect.configResolver`), read at each pane start, attach or + * relaunch, ranked below any report from the running CLI. + * + * The reader is mocked here so the session's own rules are what is tested: when it + * reads, with what, and which answer wins. The reader itself (dsh-TUI's route) is + * `test/deepseek-route-config.test.ts`; one end-to-end read over a fixture dsh home + * is in `test/session-display-model.test.ts`. + * + * Port: N/A. + */ +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; + +const resolveConfigModel = vi.fn(); +vi.mock('../src/model-config-resolvers.js', () => ({ + resolveConfigModel: (...a: unknown[]) => resolveConfigModel(...a), +})); + +const { Session } = await import('../src/session.js'); + +type Internals = { + _withPaneLifecycle(op: () => Promise): Promise; + _isStopped: boolean; +}; + +/** A deferred answer, so a test decides when (and in which order) reads land. */ +function deferred() { + let resolve!: (v: string | null) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +function session(extra: Record = {}, mode = 'deepseek') { + return new Session({ workingDir: '/tmp', mode, ...extra } as ConstructorParameters[0]); +} +/** One pane start: what triggers the read. */ +const start = (s: InstanceType) => (s as unknown as Internals)._withPaneLifecycle(async () => {}); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + resolveConfigModel.mockReset(); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("a session's config model", () => { + it("is read at each pane start, with the session's own config and env", async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + // The server has a dsh home of its own; the session's override is the one its CLI gets. + const before = process.env.DSH_HOME; + process.env.DSH_HOME = '/srv/server-dsh'; + onTestFinished(() => { + if (before === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = before; + }); + const s = session({ deepSeekConfig: { profile: 'dsh-tui' }, envOverrides: { DSH_HOME: '/srv/dsh-copy' } }); + const changed = vi.fn(); + s.on('displayModelChanged', changed); + expect(resolveConfigModel).not.toHaveBeenCalled(); + await start(s); + await flush(); + expect(resolveConfigModel).toHaveBeenCalledTimes(1); + const [name, ctx] = resolveConfigModel.mock.calls[0]; + expect(name).toBe('deepseek-route'); + expect(ctx.config).toEqual({ profile: 'dsh-tui' }); + expect(ctx.env('DSH_HOME')).toBe('/srv/dsh-copy'); + expect(s.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' }); + expect(changed).toHaveBeenCalledTimes(1); + // A relaunch reads again; the same answer announces nothing new. + await start(s); + await flush(); + expect(resolveConfigModel).toHaveBeenCalledTimes(2); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('without a session DSH_HOME the server one is what the CLI inherits', async () => { + resolveConfigModel.mockResolvedValue(null); + const before = process.env.DSH_HOME; + process.env.DSH_HOME = '/srv/server-dsh'; + try { + const s = session(); + await start(s); + expect(resolveConfigModel.mock.calls[0][1].env('DSH_HOME')).toBe('/srv/server-dsh'); + } finally { + if (before === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = before; + } + }); + + it('the screen wins over the config whenever it names a model, in either order', async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + const s = session(); + await start(s); + await flush(); + s.noteReportedModel('screen', 'deepseek-v4-flash'); + expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + await start(s); + await flush(); + expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + }); + + it('a screen value restored after a restart outranks a fresh config read', async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + const s = session({ displayModel: { model: 'deepseek-v4-flash', source: 'screen' } }); + await start(s); + await flush(); + expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + }); + + it('the config outranks the launch model, and a config that pins nothing leaves it', async () => { + resolveConfigModel.mockResolvedValue('qwen-from-config'); + const codex = session({ codexConfig: { model: 'gpt-5.5' } }, 'codex'); + await start(codex); + await flush(); + // codex declares no config reader: nothing is read, the launch model stays. + expect(resolveConfigModel).not.toHaveBeenCalled(); + expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' }); + resolveConfigModel.mockResolvedValue(null); + const s = session(); + await start(s); + await flush(); + expect(s.toState().displayModel).toBeUndefined(); + }); + + it('a read that lands after a newer one is dropped, and so is one after the session stopped', async () => { + const first = deferred(); + const second = deferred(); + resolveConfigModel.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const s = session(); + await start(s); + await start(s); + second.resolve('newer'); + await flush(); + first.resolve('older'); + await flush(); + expect(s.toState().displayModel?.model).toBe('newer'); + + const late = deferred(); + resolveConfigModel.mockReturnValueOnce(late.promise); + const stopped = session(); + await start(stopped); + (stopped as unknown as Internals)._isStopped = true; + late.resolve('too late'); + await flush(); + expect(stopped.toState().displayModel).toBeUndefined(); + }); + + it('a remote or docker session reads nothing locally', async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + await start(session({ remote: { hostId: 'h', label: 'h', host: 'h', username: 'u', remotePath: '/w' } })); + await start( + session({ + docker: { + hostId: 'd', + label: 'd', + engine: 'docker', + image: 'i', + containerName: 'c', + hostWorkspacePath: '/w', + containerWorkdir: '/w', + }, + }) + ); + await flush(); + expect(resolveConfigModel).not.toHaveBeenCalled(); + }); + + it('a config model is untrusted text: control characters dropped, length capped', async () => { + resolveConfigModel.mockResolvedValue(`\x1b[31mqwen\x1b[0m${'x'.repeat(200)}`); + const s = session(); + await start(s); + await flush(); + const dm = s.toState().displayModel!; + expect(dm.source).toBe('config'); + expect(dm.model.startsWith('qwenx')).toBe(true); + expect(dm.model.length).toBe(64); + }); +}); diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts index 160e5ec8..3421c189 100644 --- a/test/session-display-model.test.ts +++ b/test/session-display-model.test.ts @@ -7,6 +7,9 @@ * 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'; @@ -91,6 +94,21 @@ describe('the registry patterns', () => { expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).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)(?c)')).toBe(1); expect(countCaptureGroups('(a)(b)')).toBe(2); @@ -181,6 +199,16 @@ describe('resolveDisplayModel', () => { 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(); @@ -195,6 +223,7 @@ describe('restoredReportedModel', () => { }); 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(); @@ -326,3 +355,37 @@ describe('a session', () => { 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[0]); + await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._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 }); + } + }); +});