mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(deepseek): read the model a dsh session's TUI route config pins
A DeepSeek session whose screen names no model (dsh-TUI's status bar model field off, or not drawn yet) can still name the model its route config pins (owner request). src/deepseek-route-config.ts resolves it the way dsh and dsh-TUI 0.10.0-beta.1 do, for the session's profile (else the one the launch boots) under the session's dsh home: - dsh composes a profile from patch layers: the bundles, then profiles/<profile>/cordis.patch.yml, then $DSH_HOME/cordis.patch.yml (which outranks it). A patch's `config` replaces the dsh-tui row's whole config, a `name` mismatch skips it, `disabled` turns the row off. - dsh-TUI takes its route from that config only when it names BOTH provider and model (modelRoute.js); anything less falls back to state the config does not hold, so the answer is nothing. The bundle row pins a provider alone by design and is not read (it resolves outside the dsh home); settings.yaml's agent-default-model is the headless default and is never read. - Any doubt answers nothing: a profile without dsh-TUI, a half-pinned route, an unreadable or oversized layer, a symlink out of the dsh home, a mount that does not answer, a file beyond a narrow strict YAML subset (no dependency added: plain keys, single-line string scalars for the values it needs; tags, anchors, aliases, merge keys, multi-line scalars, flow or block-scalar config, duplicate keys, typed scalars, a second document, or a nested row re-defining dsh-tui all answer null). - Bounded and read-only: every path is probed with probePathKind() first, read async with a 64 KiB cap, and must realpath inside the dsh home. Only the model id leaves the module. The default-profile inventory reuses the resolver's classification through a new pure deepSeekProfileFromManifest(), read with the same bounded rules. Not wired to sessions yet; the next commit does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The model a DeepSeek Harness (`dsh`) session's TUI is configured to
|
||||||
|
* use, read from its route config, for a session header whose screen names no model
|
||||||
|
* yet (the status bar's model field switched off, or not drawn yet). See
|
||||||
|
* `SessionState.displayModel` (src/session-display-model.ts): the screen still wins
|
||||||
|
* whenever it names a model, since it is what the running TUI actually uses.
|
||||||
|
*
|
||||||
|
* ## How dsh-TUI resolves its route (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1)
|
||||||
|
*
|
||||||
|
* A profile is a stack of loader patch layers over an empty root, in this order
|
||||||
|
* (`@deepseek-ai/dsh` profile-boot): every bundle's patch layer, the profile's own
|
||||||
|
* `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the home-level
|
||||||
|
* `$DSH_HOME/cordis.patch.yml` (it outranks the profile layer), then `--patch`
|
||||||
|
* overlays (Codeman passes none). A patch targets a row by `id`; one whose `name`
|
||||||
|
* does not match the row's is skipped; every other key REPLACES the row's field
|
||||||
|
* whole (`applyEntryPatches`), so the last layer carrying `config` for the `dsh-tui`
|
||||||
|
* row defines all of it.
|
||||||
|
*
|
||||||
|
* dsh-TUI then takes its model route from that config only when it names BOTH
|
||||||
|
* `provider` and `model` (`lib/types/modelRoute.js`, issue #67). Anything less is
|
||||||
|
* dropped whole and the TUI falls back to the persisted `/model` choice, then to its
|
||||||
|
* own default: neither is in the config, so neither is answered here. The bundle's
|
||||||
|
* own row pins `provider: deepseek-official` alone, by design, so only the two user
|
||||||
|
* layers can pin a route; the bundle layers are not read (they resolve through the dsh
|
||||||
|
* installation, outside the dsh home). `settings.yaml`'s `agent-default-model` is the
|
||||||
|
* HEADLESS default, not the TUI's, and is never read.
|
||||||
|
*
|
||||||
|
* ## Answer nothing rather than a guess
|
||||||
|
*
|
||||||
|
* Every doubt answers null: a profile that does not compose dsh-TUI, a half-pinned
|
||||||
|
* route, a layer that cannot be read (unreadable, a symlink out of the dsh home, too
|
||||||
|
* big, a mount that does not answer), and a file this reader does not fully
|
||||||
|
* understand. The YAML reader below is deliberately narrow (the repo carries no YAML
|
||||||
|
* dependency): a top-level block sequence of patch items, plain keys, single-line
|
||||||
|
* plain or quoted string scalars for the values it needs, and null for anything else
|
||||||
|
* that could change the answer (an anchor, alias or tag such as `!!js` on such a value,
|
||||||
|
* a merge key, a multi-line scalar, flow or block-scalar config, duplicate keys, a
|
||||||
|
* scalar YAML would type as a number, boolean or null, a second document, a nested
|
||||||
|
* row redefining dsh-TUI).
|
||||||
|
*
|
||||||
|
* ## Never block, never write, never leak
|
||||||
|
*
|
||||||
|
* Every path is probed with the bounded `probePathKind()` before it is touched, read
|
||||||
|
* asynchronously with a size cap, and must resolve (realpath) inside the dsh home.
|
||||||
|
* Nothing is written. Only the model id leaves this module: never the provider, a
|
||||||
|
* base URL, a key or any other config value.
|
||||||
|
*
|
||||||
|
* Tests: `test/deepseek-route-config.test.ts`.
|
||||||
|
*
|
||||||
|
* @module deepseek-route-config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||||
|
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||||
|
import {
|
||||||
|
deepSeekProfileFromManifest,
|
||||||
|
isProfileDirName,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
|
type DeepSeekProfile,
|
||||||
|
} from './utils/deepseek-cli-resolver.js';
|
||||||
|
|
||||||
|
/** The dsh-TUI bundle a profile must compose for its route to be read here. */
|
||||||
|
export const DSH_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
|
||||||
|
/** The loader row dsh-TUI's own config lives on. */
|
||||||
|
export const DSH_TUI_ROW_ID = 'dsh-tui';
|
||||||
|
/** Largest file read: a patch layer is a few dozen lines. */
|
||||||
|
export const MAX_ROUTE_FILE_BYTES = 64 * 1024;
|
||||||
|
/** A profile name as the launch accepts it (the `path-segment` token pattern). */
|
||||||
|
const PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||||
|
/** How many profile directories the default-profile inventory looks at. */
|
||||||
|
const MAX_PROFILES = 64;
|
||||||
|
|
||||||
|
/** What one patch item does to the dsh-TUI row. */
|
||||||
|
export interface DshTuiRowPatch {
|
||||||
|
/** `name` on the patch; a mismatch makes dsh skip it. */
|
||||||
|
name?: string;
|
||||||
|
/** `disabled` on the patch, when present. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* `config` on the patch, when present: the provider and model it names (absent when
|
||||||
|
* it does not name one), or `{}` for a config that is empty or not a mapping.
|
||||||
|
*/
|
||||||
|
config?: { provider?: string; model?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown inside the parser for anything it does not fully understand. */
|
||||||
|
class Ambiguous extends Error {}
|
||||||
|
|
||||||
|
/** A nested line naming the dsh-TUI row: a group's config can re-define the row through it. */
|
||||||
|
const ROW_ID_LINE = /^(?:-\s+)?id:\s*(['"]?)dsh-tui\1\s*$/;
|
||||||
|
|
||||||
|
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a line's comment (`#` at line start or after whitespace, outside quotes) and
|
||||||
|
* trailing blanks. Throws on an unterminated quote: a multi-line flow scalar is
|
||||||
|
* beyond this reader.
|
||||||
|
*/
|
||||||
|
function stripComment(line: string): string {
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quote === "'") {
|
||||||
|
if (c === "'") {
|
||||||
|
if (line[i + 1] === "'") i++;
|
||||||
|
else quote = null;
|
||||||
|
}
|
||||||
|
} else if (quote === '"') {
|
||||||
|
if (c === '\\') i++;
|
||||||
|
else if (c === '"') quote = null;
|
||||||
|
} else if (c === "'" || c === '"') {
|
||||||
|
// A quote opens a scalar only at its start; inside a plain scalar it is a character.
|
||||||
|
const prev = line.slice(0, i).trimEnd();
|
||||||
|
if (prev === '' || /[:\-[{,]$/.test(prev)) quote = c;
|
||||||
|
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||||
|
return line.slice(0, i).trimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (quote) throw new Ambiguous('unterminated quote');
|
||||||
|
return line.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indentation of a line; a tab in it is refused (YAML forbids tabs there). */
|
||||||
|
function indentOf(line: string): number {
|
||||||
|
const m = /^[ \t]*/.exec(line)![0];
|
||||||
|
if (m.includes('\t')) throw new Ambiguous('tab indentation');
|
||||||
|
return m.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-line scalar as a string: plain, or single/double-quoted. Throws on anything
|
||||||
|
* that is not plainly a string (a tag, an anchor, an alias, a flow collection, a
|
||||||
|
* block scalar, or a plain scalar YAML would type as null, a boolean or a number).
|
||||||
|
*/
|
||||||
|
function stringScalar(raw: string): string {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (v.startsWith("'")) {
|
||||||
|
const m = /^'((?:[^']|'')*)'$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/''/g, "'");
|
||||||
|
}
|
||||||
|
if (v.startsWith('"')) {
|
||||||
|
const m = /^"((?:[^"\\]|\\["\\/])*)"$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/\\(["\\/])/g, '$1');
|
||||||
|
}
|
||||||
|
if (v === '' || /^[!&*[\]{}|>%@`,?:-]/.test(v)) throw new Ambiguous('not a plain string');
|
||||||
|
if (/^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/.test(v)) throw new Ambiguous('typed scalar');
|
||||||
|
if (
|
||||||
|
/^[-+]?(?:\.\d+|\d[\d_]*(?:\.\d*)?)(?:[eE][-+]?\d+)?$|^0[xob][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/.test(
|
||||||
|
v
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Ambiguous('numeric scalar');
|
||||||
|
}
|
||||||
|
if (/\s#|:\s/.test(v)) throw new Ambiguous('plain scalar with an indicator');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true`/`false` as YAML spells them, or a throw. */
|
||||||
|
function boolScalar(raw: string): boolean {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (/^(?:true|True|TRUE)$/.test(v)) return true;
|
||||||
|
if (/^(?:false|False|FALSE)$/.test(v)) return false;
|
||||||
|
throw new Ambiguous('not a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
indent: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direct keys of a block mapping whose lines all sit at `indent` or deeper, each
|
||||||
|
* with its inline value and the lines nested under it. Throws on a line that is not a
|
||||||
|
* key at the mapping's indent, and on a duplicate key (js-yaml refuses those, so dsh
|
||||||
|
* would not boot).
|
||||||
|
*/
|
||||||
|
function mappingKeys(lines: Line[], indent: number): Map<string, { inline: string | undefined; nested: Line[] }> {
|
||||||
|
const keys = new Map<string, { inline: string | undefined; nested: Line[] }>();
|
||||||
|
let current: { inline: string | undefined; nested: Line[] } | null = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent > indent) {
|
||||||
|
if (!current) throw new Ambiguous('nested line with no key');
|
||||||
|
current.nested.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.indent < indent) throw new Ambiguous('dedent inside a mapping');
|
||||||
|
const m = KEY_LINE.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous(`not a key: ${line.text.slice(0, 20)}`);
|
||||||
|
if (keys.has(m[1])) throw new Ambiguous('duplicate key');
|
||||||
|
current = { inline: m[2] === undefined || m[2] === '' ? undefined : m[2], nested: [] };
|
||||||
|
keys.set(m[1], current);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an item this reader cannot follow is certainly about another row: a plain
|
||||||
|
* `id:` at the item's indent naming a row other than dsh-TUI, no `insert:` and no
|
||||||
|
* mention of the dsh-TUI row anywhere in it.
|
||||||
|
*/
|
||||||
|
function isUnrelatedItem(item: Line[]): boolean {
|
||||||
|
const indent = item[0].indent;
|
||||||
|
const top = item.filter((l) => l.indent === indent);
|
||||||
|
if (top.some((l) => /^insert\s*:/.test(l.text))) return false;
|
||||||
|
const ids = top.map((l) => KEY_LINE.exec(l.text)).filter((m) => m?.[1] === 'id');
|
||||||
|
if (ids.length !== 1 || ids[0]![2] === undefined) return false;
|
||||||
|
try {
|
||||||
|
return stringScalar(ids[0]![2]) !== DSH_TUI_ROW_ID;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `config` of a dsh-TUI patch: its provider and model, if it names them. */
|
||||||
|
function configOf(entry: { inline: string | undefined; nested: Line[] }): { provider?: string; model?: string } {
|
||||||
|
if (entry.inline !== undefined) {
|
||||||
|
if (entry.nested.length) throw new Ambiguous('config with both an inline value and nested lines');
|
||||||
|
const v = entry.inline.trim();
|
||||||
|
// An empty flow mapping or a null names no route; anything else inline (a tag, a
|
||||||
|
// non-empty flow mapping, a block scalar) is beyond this reader.
|
||||||
|
if (v === '{}' || /^(?:~|null|Null|NULL)$/.test(v)) return {};
|
||||||
|
throw new Ambiguous('inline config');
|
||||||
|
}
|
||||||
|
if (!entry.nested.length) return {};
|
||||||
|
const keys = mappingKeys(entry.nested, entry.nested[0].indent);
|
||||||
|
const out: { provider?: string; model?: string } = {};
|
||||||
|
for (const field of ['provider', 'model'] as const) {
|
||||||
|
const value = keys.get(field);
|
||||||
|
if (!value) continue;
|
||||||
|
if (value.nested.length || value.inline === undefined) throw new Ambiguous(`${field} is not a single-line scalar`);
|
||||||
|
out[field] = stringScalar(value.inline);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a cordis patch-list file (a top-level YAML array of loader patches) does to the
|
||||||
|
* dsh-TUI row, in order. An empty list when the file does not touch it. Null when the
|
||||||
|
* file is beyond this reader's subset, or when it could re-insert the row.
|
||||||
|
*
|
||||||
|
* @param text the file's content
|
||||||
|
*/
|
||||||
|
export function parseDshTuiPatches(text: string): DshTuiRowPatch[] | null {
|
||||||
|
try {
|
||||||
|
const lines: Line[] = [];
|
||||||
|
let sawContent = false;
|
||||||
|
for (const rawLine of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
||||||
|
const stripped = stripComment(rawLine);
|
||||||
|
if (stripped.trim() === '') continue;
|
||||||
|
const indent = indentOf(stripped);
|
||||||
|
const body = stripped.slice(indent);
|
||||||
|
if (indent === 0 && body === '---') {
|
||||||
|
if (sawContent) throw new Ambiguous('a second document');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sawContent = true;
|
||||||
|
lines.push({ indent, text: body });
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
if (lines.length === 1 && lines[0].indent === 0 && lines[0].text === '[]') return [];
|
||||||
|
|
||||||
|
// Split the top-level block sequence into items.
|
||||||
|
const items: Line[][] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent === 0) {
|
||||||
|
const m = /^-(?:(\s+)(.*))?$/.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous('not a top-level sequence');
|
||||||
|
const item: Line[] = [];
|
||||||
|
// `- key: value`: the key sits at its real column, which its siblings below share.
|
||||||
|
if (m[2] !== undefined && m[2] !== '') item.push({ indent: 1 + m[1].length, text: m[2] });
|
||||||
|
items.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (items.length === 0) throw new Ambiguous('indented content before the first item');
|
||||||
|
items[items.length - 1].push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patches: DshTuiRowPatch[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.length === 0) throw new Ambiguous('empty item');
|
||||||
|
// The first key's column is the item's indent; every key shares it.
|
||||||
|
let keys: ReturnType<typeof mappingKeys>;
|
||||||
|
try {
|
||||||
|
keys = mappingKeys(item, item[0].indent);
|
||||||
|
} catch (err) {
|
||||||
|
// An item this reader cannot follow is harmless only when it provably is about
|
||||||
|
// another row: its own id names one, and no line in it names the dsh-TUI row.
|
||||||
|
if (!(err instanceof Ambiguous) || !isUnrelatedItem(item)) throw err;
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = keys.get('id');
|
||||||
|
if (keys.has('insert')) {
|
||||||
|
// An insert that could bring a second dsh-TUI row is beyond this reader.
|
||||||
|
const body = item.map((l) => l.text).join('\n');
|
||||||
|
if (body.includes(DSH_TUI_ROW_ID)) throw new Ambiguous('insert mentioning the dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!id) continue; // dsh warns and skips a non-insert patch without an id
|
||||||
|
if (id.nested.length || id.inline === undefined) throw new Ambiguous('id is not a scalar');
|
||||||
|
if (stringScalar(id.inline) !== DSH_TUI_ROW_ID) {
|
||||||
|
// Another row; but a group row's config is a list of rows, and one of them could
|
||||||
|
// be a second dsh-TUI row (dsh indexes nested group entries by id too).
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const patch: DshTuiRowPatch = {};
|
||||||
|
const name = keys.get('name');
|
||||||
|
if (name) {
|
||||||
|
if (name.nested.length || name.inline === undefined) throw new Ambiguous('name is not a scalar');
|
||||||
|
patch.name = stringScalar(name.inline);
|
||||||
|
}
|
||||||
|
const disabled = keys.get('disabled');
|
||||||
|
if (disabled) {
|
||||||
|
if (disabled.nested.length || disabled.inline === undefined) throw new Ambiguous('disabled is not a scalar');
|
||||||
|
patch.disabled = boolScalar(disabled.inline);
|
||||||
|
}
|
||||||
|
const config = keys.get('config');
|
||||||
|
if (config) patch.config = configOf(config);
|
||||||
|
patches.push(patch);
|
||||||
|
}
|
||||||
|
return patches;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Ambiguous) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model dsh-TUI's route config pins, given what the user layers do to its row in
|
||||||
|
* application order (profile layer first, then the home layer). Null unless the last
|
||||||
|
* `config` that applies names both a provider and a model, and the row is not
|
||||||
|
* disabled. Pure.
|
||||||
|
*
|
||||||
|
* @param layers each layer's patches for the row, or null for a layer that could not be read
|
||||||
|
*/
|
||||||
|
export function resolveDshTuiRouteModel(layers: Array<DshTuiRowPatch[] | null>): string | null {
|
||||||
|
let config: { provider?: string; model?: string } | undefined;
|
||||||
|
let disabled = false;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer === null) return null;
|
||||||
|
for (const patch of layer) {
|
||||||
|
if (patch.name !== undefined && patch.name !== DSH_TUI_PACKAGE) continue;
|
||||||
|
if (patch.disabled !== undefined) disabled = patch.disabled;
|
||||||
|
if (patch.config !== undefined) config = patch.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disabled || !config?.provider || !config.model) return null;
|
||||||
|
return config.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file under the dsh home, read only if it provably is one; see {@link readHomeFile}. */
|
||||||
|
type FileRead = { state: 'absent' } | { state: 'read'; text: string } | { state: 'refused' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `path`, which must resolve inside `realHome`, bounded: probed first (a mount
|
||||||
|
* that does not answer is refused, never waited on), its real path checked against
|
||||||
|
* the dsh home (a symlink out of it is refused), size-capped.
|
||||||
|
*/
|
||||||
|
async function readHomeFile(path: string, realHome: string): Promise<FileRead> {
|
||||||
|
const kind = await probePathKind(path);
|
||||||
|
if (kind === 'absent') return { state: 'absent' };
|
||||||
|
if (kind !== 'file') return { state: 'refused' };
|
||||||
|
try {
|
||||||
|
const real = await fs.realpath(path);
|
||||||
|
if (!real.startsWith(realHome + sep)) return { state: 'refused' };
|
||||||
|
const stat = await fs.stat(real);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_ROUTE_FILE_BYTES) return { state: 'refused' };
|
||||||
|
return { state: 'read', text: await fs.readFile(real, 'utf8') };
|
||||||
|
} catch {
|
||||||
|
return { state: 'refused' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dsh home a session runs against: its own `DSH_HOME` (already clamped for a
|
||||||
|
* non-granted owner), else the server's, else `~/.dsh`, as the `dsh` wrapper's
|
||||||
|
* `${DSH_HOME:-...}` resolves it. Null for a relative value, which names no place.
|
||||||
|
*/
|
||||||
|
export function effectiveDshHome(env: (key: string) => string | undefined): string | null {
|
||||||
|
const value = env('DSH_HOME')?.trim();
|
||||||
|
if (!value) return join(homedir(), '.dsh');
|
||||||
|
return isAbsolute(value) ? resolve(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profiles under a dsh home, read with the same bounded rules as the route files.
|
||||||
|
* Used to name the profile a session boots when it named none, the way the launch's
|
||||||
|
* `launcherDefaultTarget` does (resolveDefaultDeepSeekProfile).
|
||||||
|
*/
|
||||||
|
async function listProfilesBounded(home: string): Promise<DeepSeekProfile[] | null> {
|
||||||
|
const profilesDir = join(home, 'profiles');
|
||||||
|
if ((await probePathKind(home)) !== 'directory' || (await probePathKind(profilesDir)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
names = (await fs.readdir(profilesDir, { withFileTypes: true }))
|
||||||
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name) && PROFILE_NAME.test(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.slice(0, MAX_PROFILES);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profiles: DeepSeekProfile[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const manifest = await readHomeFile(join(profilesDir, name, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') continue;
|
||||||
|
const profile = deepSeekProfileFromManifest(name, manifest.text);
|
||||||
|
if (profile) profiles.push(profile);
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the reader needs to know about one session. */
|
||||||
|
export interface DeepSeekRouteContext {
|
||||||
|
/** The session's `deepSeekConfig.profile`, if any. */
|
||||||
|
profile?: unknown;
|
||||||
|
/** The session's dsh home (see {@link effectiveDshHome}). */
|
||||||
|
home: string | null;
|
||||||
|
/** The server's own dsh home, which names the default profile (as the launch does). */
|
||||||
|
serverHome: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model the session's dsh-TUI route config pins, or null when it pins none or the
|
||||||
|
* answer is in any doubt. Read-only and bounded; see the module comment.
|
||||||
|
*/
|
||||||
|
export async function readDeepSeekRouteModel(ctx: DeepSeekRouteContext): Promise<string | null> {
|
||||||
|
const { home } = ctx;
|
||||||
|
if (!home) return null;
|
||||||
|
// An invalid name reads as unset at launch, so the default applies there too.
|
||||||
|
let profile = typeof ctx.profile === 'string' && PROFILE_NAME.test(ctx.profile) ? ctx.profile : null;
|
||||||
|
if (!profile) {
|
||||||
|
if (!ctx.serverHome) return null;
|
||||||
|
const listed = await listProfilesBounded(ctx.serverHome);
|
||||||
|
profile = listed ? resolveDefaultDeepSeekProfile(listed) : null;
|
||||||
|
if (!profile) return null;
|
||||||
|
}
|
||||||
|
if ((await probePathKind(home)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profileDir = join(home, 'profiles', profile);
|
||||||
|
const manifest = await readHomeFile(join(profileDir, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') return null;
|
||||||
|
if (!deepSeekProfileFromManifest(profile, manifest.text)?.bundles.includes(DSH_TUI_PACKAGE)) return null;
|
||||||
|
|
||||||
|
const layers: Array<DshTuiRowPatch[] | null> = [];
|
||||||
|
for (const file of [join(profileDir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')]) {
|
||||||
|
const read = await readHomeFile(file, realHome);
|
||||||
|
if (read.state === 'refused') return null;
|
||||||
|
layers.push(read.state === 'absent' ? [] : parseDshTuiPatches(read.text));
|
||||||
|
}
|
||||||
|
return resolveDshTuiRouteModel(layers);
|
||||||
|
}
|
||||||
@@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
|
|||||||
/** Profile directory names that are not profiles. */
|
/** Profile directory names that are not profiles. */
|
||||||
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||||
|
|
||||||
|
/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */
|
||||||
|
export function isProfileDirName(name: string): boolean {
|
||||||
|
return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.');
|
||||||
|
}
|
||||||
|
|
||||||
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||||
const haystack = [name, ...bundles].join(' ');
|
const haystack = [name, ...bundles].join(' ');
|
||||||
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
||||||
@@ -174,7 +179,19 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
|||||||
*/
|
*/
|
||||||
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
|
return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile from its directory name and the text of its `package.json`, or null when
|
||||||
|
* that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the
|
||||||
|
* same classification as the inventory below.
|
||||||
|
*/
|
||||||
|
export function deepSeekProfileFromManifest(name: string, raw: string): DeepSeekProfile | null {
|
||||||
|
try {
|
||||||
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
||||||
const rawBundles = parsed?.dsh?.profile?.bundles;
|
const rawBundles = parsed?.dsh?.profile?.bundles;
|
||||||
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
||||||
@@ -198,7 +215,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
|||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(profilesDir, { withFileTypes: true })
|
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||||
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name))
|
||||||
.map((e) => e.name);
|
.map((e) => e.name);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* The model a dsh session's TUI route config pins (src/deepseek-route-config.ts),
|
||||||
|
* shown in a session header when the TUI's screen names none.
|
||||||
|
*
|
||||||
|
* Fixture dsh homes are built in a temp directory. The two user layers below are the
|
||||||
|
* owner's real files on 2026-10-07, verbatim apart from the comment text: the
|
||||||
|
* profile layer pins the dsh-tui row's route (provider AND model), the home layer
|
||||||
|
* pins `agent-default-model`, which is the headless default and NOT the TUI's.
|
||||||
|
*
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DSH_TUI_PACKAGE,
|
||||||
|
MAX_ROUTE_FILE_BYTES,
|
||||||
|
effectiveDshHome,
|
||||||
|
parseDshTuiPatches,
|
||||||
|
readDeepSeekRouteModel,
|
||||||
|
resolveDshTuiRouteModel,
|
||||||
|
} from '../src/deepseek-route-config.js';
|
||||||
|
|
||||||
|
const PROFILE_LAYER = `# Your patch layer for this dsh profile, applied after every bundle layer:
|
||||||
|
# a top-level YAML array of loader patch entries (id-targeted config
|
||||||
|
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
|
||||||
|
#
|
||||||
|
# Route the TUI at the 5090 box's live serve. A config route counts only
|
||||||
|
# when it names BOTH provider and model.
|
||||||
|
- id: dsh-tui
|
||||||
|
config:
|
||||||
|
provider: qwen5090
|
||||||
|
model: qwen3.8-27b
|
||||||
|
effort: medium
|
||||||
|
preset: qwen5090
|
||||||
|
`;
|
||||||
|
const HOME_LAYER = `# Home-level patch layer. The default model is a composition entry,
|
||||||
|
# not a settings key.
|
||||||
|
- id: agent-default-model
|
||||||
|
config:
|
||||||
|
provider: qwen5090
|
||||||
|
model: qwen3.8-27b
|
||||||
|
`;
|
||||||
|
const SETTINGS = `agent-default-model:
|
||||||
|
provider: qwen5090
|
||||||
|
model: qwen3.8-27b
|
||||||
|
`;
|
||||||
|
const TUI_BUNDLES = ['@deepseek-ai/dsh-base', DSH_TUI_PACKAGE];
|
||||||
|
|
||||||
|
const homes: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A fixture dsh home: profiles (bundles + optional patch layer), a home layer, settings.yaml. */
|
||||||
|
function makeHome(spec: {
|
||||||
|
profiles?: Record<string, { bundles?: string[]; patch?: string }>;
|
||||||
|
homePatch?: string;
|
||||||
|
settings?: string;
|
||||||
|
}): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'dsh-route-'));
|
||||||
|
homes.push(root);
|
||||||
|
const home = join(root, 'dsh');
|
||||||
|
mkdirSync(join(home, 'profiles'), { recursive: true });
|
||||||
|
for (const [name, p] of Object.entries(spec.profiles ?? {})) {
|
||||||
|
mkdirSync(join(home, 'profiles', name), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'profiles', name, 'package.json'),
|
||||||
|
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles: p.bundles ?? TUI_BUNDLES } } })
|
||||||
|
);
|
||||||
|
if (p.patch !== undefined) writeFileSync(join(home, 'profiles', name, 'cordis.patch.yml'), p.patch);
|
||||||
|
}
|
||||||
|
if (spec.homePatch !== undefined) writeFileSync(join(home, 'cordis.patch.yml'), spec.homePatch);
|
||||||
|
if (spec.settings !== undefined) writeFileSync(join(home, 'settings.yaml'), spec.settings);
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = (home: string, profile: string | undefined = 'dsh-tui', serverHome: string | null = home) =>
|
||||||
|
readDeepSeekRouteModel({ profile, home, serverHome });
|
||||||
|
|
||||||
|
describe('readDeepSeekRouteModel', () => {
|
||||||
|
it('a route pinned with both provider and model: the model', async () => {
|
||||||
|
const home = makeHome({
|
||||||
|
profiles: { 'dsh-tui': { patch: PROFILE_LAYER } },
|
||||||
|
homePatch: HOME_LAYER,
|
||||||
|
settings: SETTINGS,
|
||||||
|
});
|
||||||
|
expect(await read(home)).toBe('qwen3.8-27b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a half-pinned route (provider alone): nothing, the TUI drops it whole', async () => {
|
||||||
|
const half = '- id: dsh-tui\n config:\n provider: qwen5090\n effort: medium\n';
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: half } } }))).toBeNull();
|
||||||
|
const modelOnly = '- id: dsh-tui\n config:\n model: qwen3.8-27b\n';
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: modelOnly } } }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no profile layer: nothing (the bundle pins a provider alone, by design)', async () => {
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER, settings: SETTINGS }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settings.yaml and the home layer's agent-default-model are the headless default, never the TUI's", async () => {
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, settings: SETTINGS }))).toBeNull();
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a layer that does not parse: nothing', async () => {
|
||||||
|
const broken = [
|
||||||
|
'- id: dsh-tui\n config:\n\tprovider: qwen5090\n\tmodel: x\n', // tab indentation
|
||||||
|
'- id: dsh-tui\n config:\n \tprovider: qwen5090\n \tmodel: x\n', // a tab inside the indentation
|
||||||
|
"- id: dsh-tui\n config:\n provider: 'qwen5090\n model: x\n", // unterminated quote
|
||||||
|
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n model: other\n', // duplicate key
|
||||||
|
'dsh-tui:\n provider: qwen5090\n', // not a sequence
|
||||||
|
];
|
||||||
|
for (const patch of broken) {
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch } } })), patch).toBeNull();
|
||||||
|
}
|
||||||
|
// The home layer is read too: a broken one blanks a good profile layer.
|
||||||
|
const home = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: '- id: [\n' });
|
||||||
|
expect(await read(home)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a layer that is a symlink out of the dsh home: nothing; one inside it is followed', async () => {
|
||||||
|
const home = makeHome({ profiles: { 'dsh-tui': {} } });
|
||||||
|
const outside = join(home, '..', 'outside.yml');
|
||||||
|
writeFileSync(outside, PROFILE_LAYER);
|
||||||
|
symlinkSync(outside, join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
|
||||||
|
expect(await read(home)).toBeNull();
|
||||||
|
|
||||||
|
const inside = makeHome({ profiles: { 'dsh-tui': {} } });
|
||||||
|
writeFileSync(join(inside, 'shared-route.yml'), PROFILE_LAYER);
|
||||||
|
symlinkSync(join(inside, 'shared-route.yml'), join(inside, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
|
||||||
|
expect(await read(inside)).toBe('qwen3.8-27b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a session's own DSH_HOME is the one read", async () => {
|
||||||
|
const server = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } });
|
||||||
|
const own = makeHome({
|
||||||
|
profiles: {
|
||||||
|
'dsh-tui': { patch: '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-pro\n' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await read(own, 'dsh-tui', server)).toBe('deepseek-v4-pro');
|
||||||
|
expect(await read(server, 'dsh-tui', server)).toBe('qwen3.8-27b');
|
||||||
|
// The status bar can be switched off in that home without touching the route.
|
||||||
|
expect(effectiveDshHome((k) => (k === 'DSH_HOME' ? own : undefined))).toBe(own);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the home layer outranks the profile layer, and replaces its whole config', async () => {
|
||||||
|
const pin = '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-flash\n';
|
||||||
|
const homeWins = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: pin });
|
||||||
|
expect(await read(homeWins)).toBe('deepseek-v4-flash');
|
||||||
|
const half = '- id: dsh-tui\n config:\n effort: high\n';
|
||||||
|
const halved = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: half });
|
||||||
|
expect(await read(halved)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no profile named: the one the launch would boot (the first terminal profile)', async () => {
|
||||||
|
const home = makeHome({
|
||||||
|
profiles: {
|
||||||
|
web: { bundles: ['@deepseek-ai/dsh-web-app'] },
|
||||||
|
'dsh-tui': { patch: PROFILE_LAYER },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await read(home, undefined)).toBe('qwen3.8-27b');
|
||||||
|
// An invalid name reads as unset at launch, so the default applies here too.
|
||||||
|
expect(await read(home, '../web')).toBe('qwen3.8-27b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a profile that does not compose dsh-TUI: nothing (its route is not this reader’s to know)', async () => {
|
||||||
|
const home = makeHome({ profiles: { other: { bundles: ['@someone/other-tui'], patch: PROFILE_LAYER } } });
|
||||||
|
expect(await read(home, 'other')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a disabled row, a name mismatch, a missing home or profile: nothing', async () => {
|
||||||
|
const disabled = `${PROFILE_LAYER}- id: dsh-tui\n disabled: true\n`;
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: disabled } } }))).toBeNull();
|
||||||
|
const mismatch = '- id: dsh-tui\n name: "@someone/else"\n config:\n provider: a\n model: b\n';
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: mismatch } } }))).toBeNull();
|
||||||
|
expect(await read(join(tmpdir(), 'no-such-dsh-home-xyz'))).toBeNull();
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } }), 'missing')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an oversized layer: nothing', async () => {
|
||||||
|
const big = PROFILE_LAYER + `# ${'x'.repeat(MAX_ROUTE_FILE_BYTES)}\n`;
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: big } } }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('effectiveDshHome', () => {
|
||||||
|
it("the session's, else ~/.dsh; a relative value names no place", () => {
|
||||||
|
expect(effectiveDshHome(() => '/srv/dsh')).toBe('/srv/dsh');
|
||||||
|
expect(effectiveDshHome(() => ' ')).toMatch(/\.dsh$/);
|
||||||
|
expect(effectiveDshHome(() => undefined)).toMatch(/\.dsh$/);
|
||||||
|
expect(effectiveDshHome(() => 'dsh-copy')).toBeNull();
|
||||||
|
expect(effectiveDshHome(() => '~/dsh')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseDshTuiPatches (the narrow YAML subset)', () => {
|
||||||
|
it("reads the owner's two layers", () => {
|
||||||
|
expect(parseDshTuiPatches(PROFILE_LAYER)).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
|
||||||
|
expect(parseDshTuiPatches(HOME_LAYER)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty list, comments only, and a leading document marker', () => {
|
||||||
|
expect(parseDshTuiPatches('[]\n')).toEqual([]);
|
||||||
|
expect(parseDshTuiPatches('# nothing yet\n')).toEqual([]);
|
||||||
|
expect(parseDshTuiPatches(`---\n${PROFILE_LAYER}`)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quoted scalars, a key on its own line under `-`, a wider item indent', () => {
|
||||||
|
expect(
|
||||||
|
parseDshTuiPatches('- id: "dsh-tui"\n config:\n provider: "qwen5090"\n model: \'qwen3.8-27b\'\n')
|
||||||
|
).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
|
||||||
|
expect(parseDshTuiPatches('-\n id: dsh-tui\n config:\n provider: p\n model: m # trailing\n')).toEqual([
|
||||||
|
{ config: { provider: 'p', model: 'm' } },
|
||||||
|
]);
|
||||||
|
expect(parseDshTuiPatches('- id: dsh-tui\n config:\n provider: p\n model: m\n')).toEqual([
|
||||||
|
{ config: { provider: 'p', model: 'm' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anything that could change the answer and is beyond the subset: null', () => {
|
||||||
|
const cases = [
|
||||||
|
'- id: dsh-tui\n config:\n provider: !!js process.env.P\n model: m\n', // a tag
|
||||||
|
'- id: dsh-tui\n config: &route\n provider: p\n model: m\n', // an anchor
|
||||||
|
'- id: other\n config: &r { provider: p, model: m }\n- id: dsh-tui\n config: *r\n', // an alias
|
||||||
|
'- id: dsh-tui\n config:\n <<: { provider: p, model: m }\n', // a merge key
|
||||||
|
'- id: dsh-tui\n config: { provider: p, model: m }\n', // a flow config
|
||||||
|
'- id: dsh-tui\n config:\n provider: p\n model: 12\n', // a number, not a string
|
||||||
|
'- id: dsh-tui\n config:\n provider: p\n model: |\n m\n', // a block scalar
|
||||||
|
'- insert:\n - id: dsh-tui\n name: x\n', // re-inserting the row
|
||||||
|
`${PROFILE_LAYER}---\n- id: dsh-tui\n`, // a second document
|
||||||
|
'%YAML 1.2\n---\n- id: dsh-tui\n', // a directive
|
||||||
|
// A quoted scalar spanning lines swallows the next "key": YAML reads note as
|
||||||
|
// "start model: m" and the route as half-pinned.
|
||||||
|
'- id: dsh-tui\n config:\n note: "start\n model: m"\n provider: p\n',
|
||||||
|
// A plain scalar continued on the next line is one value ("m continued").
|
||||||
|
'- id: dsh-tui\n config:\n provider: p\n model: m\n continued\n',
|
||||||
|
// A group row whose config re-defines a dsh-tui row inside it.
|
||||||
|
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
|
||||||
|
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
|
||||||
|
];
|
||||||
|
for (const text of cases) expect(parseDshTuiPatches(text), text).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unrelated item it cannot follow is skipped, one about dsh-tui is not', () => {
|
||||||
|
// `rules`' sequence at its key's own indent is valid YAML but beyond the subset.
|
||||||
|
const odd = '- id: approval\n rules:\n - a\n config:\n policy: |\n line one\n';
|
||||||
|
expect(parseDshTuiPatches(`${odd}${PROFILE_LAYER}`)).toEqual([
|
||||||
|
{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } },
|
||||||
|
]);
|
||||||
|
expect(parseDshTuiPatches(`- id: dsh-tui\n config:\n rules:\n - a\n`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a disabled flag, a name, and an empty config', () => {
|
||||||
|
expect(parseDshTuiPatches('- id: dsh-tui\n disabled: true\n name: x\n config: {}\n')).toEqual([
|
||||||
|
{ disabled: true, name: 'x', config: {} },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveDshTuiRouteModel', () => {
|
||||||
|
it('the last config wins whole; a layer that could not be read blanks the answer', () => {
|
||||||
|
const full = { config: { provider: 'p', model: 'm' } };
|
||||||
|
expect(resolveDshTuiRouteModel([[full], []])).toBe('m');
|
||||||
|
expect(resolveDshTuiRouteModel([[full], [{ config: { provider: 'p' } }]])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[full], null])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[], []])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[full, { disabled: true }]])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[full, { name: '@someone/else', config: {} }]])).toBe('m');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user