mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.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. */
|
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||||
const cliId = z
|
const cliId = z
|
||||||
@@ -382,12 +382,24 @@ const capabilitiesSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
|
(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'
|
'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
|
// Bounded hard, like watchingLines: every row it adds is one more row the agent
|
||||||
// itself may be able to write.
|
// itself may be able to write.
|
||||||
screenLines: z.number().int().min(1).max(4).optional(),
|
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()
|
.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(),
|
.optional(),
|
||||||
privilegedParams: z
|
privilegedParams: z
|
||||||
.array(
|
.array(
|
||||||
|
|||||||
@@ -1243,6 +1243,9 @@ const DEEPSEEK: CliEntry = {
|
|||||||
modelDetect: {
|
modelDetect: {
|
||||||
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
|
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
|
||||||
screenLines: 3,
|
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
|
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
||||||
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ export interface CliVariant {
|
|||||||
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||||
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
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. */
|
/** 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';
|
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
|
* 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
|
* 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.
|
* 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.
|
* 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.
|
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
||||||
|
|||||||
@@ -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 `<Mode>Config`), if any. */
|
||||||
|
config: Record<string, unknown> | undefined;
|
||||||
|
/** The environment the session's CLI runs with (its own overrides, then the server's). */
|
||||||
|
env: (key: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLVERS: Record<ModelConfigResolverName, (ctx: ModelConfigContext) => Promise<string | null>> = {
|
||||||
|
// 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<string | null> {
|
||||||
|
const resolver = RESOLVERS[name];
|
||||||
|
return resolver ? resolver(ctx) : null;
|
||||||
|
}
|
||||||
@@ -13,7 +13,11 @@
|
|||||||
* in-session `/model`); a CLI whose registry entry declares
|
* in-session `/model`); a CLI whose registry entry declares
|
||||||
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
|
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
|
||||||
* probe already takes.
|
* 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 `<cli>Config.model`). What was
|
* app-wide default it was created with; another CLI's `<cli>Config.model`). What was
|
||||||
* asked for, not what was reported, so it only shows when nothing reported.
|
* 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.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.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
|
* @param input.launchModel the model the session was launched with
|
||||||
*/
|
*/
|
||||||
export function resolveDisplayModel(input: {
|
export function resolveDisplayModel(input: {
|
||||||
customModelId?: string;
|
customModelId?: string;
|
||||||
reported?: ReportedModel | null;
|
reported?: ReportedModel | null;
|
||||||
|
configModel?: string | null;
|
||||||
launchModel?: string;
|
launchModel?: string;
|
||||||
}): DisplayModel | undefined {
|
}): DisplayModel | undefined {
|
||||||
const custom = sanitizeModelName(input.customModelId);
|
const custom = sanitizeModelName(input.customModelId);
|
||||||
if (custom) return { model: custom, source: 'custom-endpoint' };
|
if (custom) return { model: custom, source: 'custom-endpoint' };
|
||||||
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
|
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
|
||||||
if (reported && input.reported) return { model: reported, source: input.reported.source };
|
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);
|
const launch = sanitizeModelName(input.launchModel);
|
||||||
if (launch) return { model: launch, source: 'launch' };
|
if (launch) return { model: launch, source: 'launch' };
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
+60
-8
@@ -138,6 +138,8 @@ import {
|
|||||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||||
} from './session-attachment-history.js';
|
} from './session-attachment-history.js';
|
||||||
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.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 {
|
import {
|
||||||
launchModelFor,
|
launchModelFor,
|
||||||
readScreenModel,
|
readScreenModel,
|
||||||
@@ -566,6 +568,14 @@ export class Session extends EventEmitter {
|
|||||||
* restart, so an idle session keeps naming its model until the next report.
|
* restart, so an idle session keeps naming its model until the next report.
|
||||||
*/
|
*/
|
||||||
private _reportedModel: ReportedModel | null = null;
|
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(). */
|
/** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */
|
||||||
private _modelLineRe: RegExp | null | undefined = undefined;
|
private _modelLineRe: RegExp | null | undefined = undefined;
|
||||||
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
|
/** 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 {
|
} finally {
|
||||||
this._paneLifecycleOps--;
|
this._paneLifecycleOps--;
|
||||||
this._paneStartedAt = Date.now();
|
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 {
|
private _modelLinePattern(): RegExp | null {
|
||||||
if (this._modelLineRe === undefined) {
|
if (this._modelLineRe === undefined) {
|
||||||
const detect = getCli(this.mode)?.capabilities.modelDetect;
|
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;
|
this._modelLineRows = detect?.screenLines ?? 1;
|
||||||
}
|
}
|
||||||
return this._modelLineRe;
|
return this._modelLineRe;
|
||||||
@@ -3219,11 +3232,19 @@ export class Session extends EventEmitter {
|
|||||||
return resolveDisplayModel({
|
return resolveDisplayModel({
|
||||||
customModelId: this._customModel?.modelId,
|
customModelId: this._customModel?.modelId,
|
||||||
reported: this._reportedModel,
|
reported: this._reportedModel,
|
||||||
// The same option bag the spawn reads its launch params from: `model` at the top
|
configModel: this._configModel,
|
||||||
// for claude (the `--model` or app-wide default it was created with; inert for
|
launchModel: launchModelFor(this.mode, this._launchOptionBag()),
|
||||||
// every other CLI, which is why it is not handed over for them), each other CLI's
|
});
|
||||||
// own `<Mode>Config`. Where the model param lives is registry data.
|
}
|
||||||
launchModel: launchModelFor(this.mode, {
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* `<Mode>Config`. Where a param lives is registry data (`legacyConfigForMode`).
|
||||||
|
*/
|
||||||
|
private _launchOptionBag(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
codexConfig: this._codexConfig,
|
codexConfig: this._codexConfig,
|
||||||
@@ -3233,8 +3254,39 @@ export class Session extends EventEmitter {
|
|||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
deepSeekConfig: this._deepSeekConfig,
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
ompConfig: this._ompConfig,
|
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. */
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -646,9 +646,11 @@ export interface CustomModelSelection {
|
|||||||
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
|
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
|
||||||
* - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch.
|
* - `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.
|
* - `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.
|
* - `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. */
|
/** The model a session runs as far as the server knows, for a session header. */
|
||||||
export interface DisplayModel {
|
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
|
* 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
|
* (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
|
* 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`
|
* (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.
|
* value is restored after a restart until the next report replaces it.
|
||||||
|
|||||||
@@ -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<T>(op: () => Promise<T>): Promise<T>;
|
||||||
|
_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<string | null>((r) => (resolve = r));
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(extra: Record<string, unknown> = {}, mode = 'deepseek') {
|
||||||
|
return new Session({ workingDir: '/tmp', mode, ...extra } as ConstructorParameters<typeof Session>[0]);
|
||||||
|
}
|
||||||
|
/** One pane start: what triggers the read. */
|
||||||
|
const start = (s: InstanceType<typeof Session>) => (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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* 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.
|
* 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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { Session } from '../src/session.js';
|
import { Session } from '../src/session.js';
|
||||||
import { getCli } from '../src/config/cli-registry/index.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);
|
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', () => {
|
it('countCaptureGroups counts named groups and ignores non-capturing ones', () => {
|
||||||
expect(countCaptureGroups('a(?:b)(?<m>c)')).toBe(1);
|
expect(countCaptureGroups('a(?:b)(?<m>c)')).toBe(1);
|
||||||
expect(countCaptureGroups('(a)(b)')).toBe(2);
|
expect(countCaptureGroups('(a)(b)')).toBe(2);
|
||||||
@@ -181,6 +199,16 @@ describe('resolveDisplayModel', () => {
|
|||||||
expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' });
|
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', () => {
|
it('knows nothing when nothing is known: no placeholder', () => {
|
||||||
expect(resolveDisplayModel({})).toBeUndefined();
|
expect(resolveDisplayModel({})).toBeUndefined();
|
||||||
expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).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 4.8', source: 'statusline' })?.source).toBe('statusline');
|
||||||
expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined();
|
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: 'x', source: 'custom-endpoint' })).toBeUndefined();
|
||||||
expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined();
|
expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined();
|
||||||
expect(restoredReportedModel('screen')).toBeUndefined();
|
expect(restoredReportedModel('screen')).toBeUndefined();
|
||||||
@@ -326,3 +355,37 @@ describe('a session', () => {
|
|||||||
expect(claude.toState().displayModel).toBeUndefined();
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user