mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
feat(sessions): publish the model each session runs (displayModel)
A session header can only name the model a session runs if the server knows
it, so SessionState gains `displayModel: { model, source }`, resolved in a pure
module (src/session-display-model.ts), strongest first:
- custom-endpoint: a Custom Model Endpoint Profile's modelId answers the
session, whatever alias the CLI prints;
- statusline / screen: the newest report from the running CLI itself.
Claude's statusLine exporter already posts model.display_name on every
render; the status-telemetry route now records it (only for a CLI with
capabilities.statusLineTelemetry). A CLI whose registry entry declares the
new capabilities.modelDetect has its footer read off the pane capture the
idle/working probe already takes (no extra tmux call), so an in-session
/model switch is followed at the next transition;
- launch: the model the session was launched with (claude's --model or the
app-wide default, another CLI's <cli>Config.model), read where the registry
says the model param lives;
- nothing known: no field, never a placeholder.
modelDetect is registry data, measured on live panes: dsh-TUI's status line
on the row under its composer (qwen3.8-27b on the owner's route) and codex's
`<model> <effort> ·` footer on its last row. Both anchor on chrome only that
CLI draws, over the last rows of the screen only; a transcript line shaped like
the footer is never taken (fixture tests). The pattern goes through
compileVersionRegex() with exactly one capture group, checked at load time.
An unreadable or covered footer keeps the last model (unlike the watching
label: a model does not stop running when something covers its row). Model
text is untrusted: escape sequences and control characters are stripped and it
is capped at 64 characters. A change emits displayModelChanged, broadcast
(session:updated) and persisted; a restart restores a CLI-reported model until
the next report.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many capture groups a regex source declares (named ones included), or -1 when it
|
||||
* does not compile. Matching the empty string against `source|` always succeeds through
|
||||
* the empty alternative, and the match array then has one slot per group.
|
||||
*/
|
||||
export function countCaptureGroups(source: string): number {
|
||||
try {
|
||||
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { McpConfigFormat } from './types.js';
|
||||
|
||||
@@ -370,6 +370,25 @@ const capabilitiesSchema = z
|
||||
model: z
|
||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||
.strict(),
|
||||
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
|
||||
// over the foot of a pane capture every time a session settles. Exactly one capture
|
||||
// group (the model), checked here so a pattern without one fails at LOAD time instead
|
||||
// of silently never naming a model.
|
||||
modelDetect: z
|
||||
.object({
|
||||
screenLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.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'
|
||||
),
|
||||
// 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(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
privilegedParams: z
|
||||
.array(
|
||||
z
|
||||
|
||||
@@ -622,6 +622,16 @@ const CODEX: CliEntry = {
|
||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||
watchingLines: 3,
|
||||
},
|
||||
// The footer under the composer, measured on a live 0.147.0 pane:
|
||||
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
|
||||
// cwd). It is the pane's LAST row, below the composer, so the transcript never
|
||||
// reaches it, and the effort word right after the model is codex's own format: an
|
||||
// open slash-command popup or a bare line of prose does not have that shape. A
|
||||
// footer without an effort word (a model with no reasoning setting) is not read,
|
||||
// and the session keeps its last known or launch model.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `,
|
||||
},
|
||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||
@@ -1222,6 +1232,18 @@ const DEEPSEEK: CliEntry = {
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Model is NOT a session field for dsh — it is a profile composition entry.
|
||||
model: { source: 'none' },
|
||||
// So the screen is where the model is known: dsh-TUI resolves the route itself
|
||||
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
|
||||
// lib/types/modelRoute.js) and its status line draws "the route requests actually
|
||||
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
|
||||
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
|
||||
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
|
||||
// The border anchors it: nothing the agent writes can sit below the composer, and a
|
||||
// suggestion popup there starts with `/` or `+`, never a model id.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
|
||||
screenLines: 3,
|
||||
},
|
||||
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
||||
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
||||
// `read-only` instead would break the workspace rather than protect it.
|
||||
|
||||
@@ -462,6 +462,23 @@ export interface CliCapabilities {
|
||||
statusLineTelemetry: boolean;
|
||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
||||
/**
|
||||
* Where this CLI draws the model it is running, so a session header can name it
|
||||
* (`SessionState.displayModel`, src/session-display-model.ts).
|
||||
*
|
||||
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
|
||||
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
|
||||
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
|
||||
* extra tmux call and re-reads the footer at every turn transition, so an in-session
|
||||
* `/model` switch is followed.
|
||||
*
|
||||
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
|
||||
* on chrome only this CLI draws (the row under its own composer, an effort word in its
|
||||
* 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.
|
||||
*/
|
||||
modelDetect?: { screenLine: string; screenLines?: number };
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Which model a session is running, as far as the server can know it
|
||||
* (`SessionState.displayModel`, shown in the tile grid's and split pane's headers).
|
||||
*
|
||||
* Pure: the session feeds it what it has and publishes the answer through `toState()`.
|
||||
*
|
||||
* ## Sources, strongest first
|
||||
*
|
||||
* 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered
|
||||
* by that endpoint's `modelId`, whatever alias the CLI itself prints.
|
||||
* 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's
|
||||
* statusLine exporter posts `model.display_name` on every render (it follows an
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* Nothing known means no field at all: the header shows the harness logo alone, never a
|
||||
* placeholder or a guess.
|
||||
*
|
||||
* ## Untrusted text
|
||||
*
|
||||
* A screen-read model is pane text, and a statusline payload is a POST body: both are
|
||||
* stripped of escape sequences and control characters, whitespace-collapsed and capped
|
||||
* here, and the browser renders the result with `textContent`.
|
||||
*
|
||||
* Tests: `test/session-display-model.test.ts`.
|
||||
*
|
||||
* @module session-display-model
|
||||
*/
|
||||
|
||||
import type { DisplayModel, DisplayModelSource } from './types/session.js';
|
||||
import { stripAnsi } from './utils/index.js';
|
||||
import { getCli } from './config/cli-registry/index.js';
|
||||
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||
|
||||
/** Longest model name published (the header truncates long before this). */
|
||||
export const MAX_DISPLAY_MODEL_CHARS = 64;
|
||||
|
||||
/** A report from the running CLI itself: the sources a restart may restore. */
|
||||
export type ReportedModelSource = Extract<DisplayModelSource, 'statusline' | 'screen'>;
|
||||
|
||||
export interface ReportedModel {
|
||||
model: string;
|
||||
source: ReportedModelSource;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
|
||||
|
||||
/**
|
||||
* A model name fit to publish, or undefined when nothing printable is left.
|
||||
*
|
||||
* @param raw anything; only a string can yield a name
|
||||
*/
|
||||
export function sanitizeModelName(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!clean) return undefined;
|
||||
return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern.
|
||||
*
|
||||
* Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern
|
||||
* can anchor on the row above), which keeps the search below the transcript: the
|
||||
* pattern itself must still anchor on chrome only that CLI draws.
|
||||
*
|
||||
* @param paneText a plain `capture-pane -p` frame, or null when it could not be read
|
||||
* @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model
|
||||
* @param tailRows how many non-blank rows from the bottom the pattern sees
|
||||
* @returns the model, or undefined when the frame shows none
|
||||
*/
|
||||
export function readScreenModel(
|
||||
paneText: string | null | undefined,
|
||||
pattern: RegExp,
|
||||
tailRows: number = 1
|
||||
): string | undefined {
|
||||
if (!paneText) return undefined;
|
||||
const rows = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((row) => row.trimEnd())
|
||||
.filter((row) => row !== '');
|
||||
const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n');
|
||||
// compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a
|
||||
// stale lastIndex would make the same frame match every other call.
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(window);
|
||||
return match ? sanitizeModelName(match[1]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model a session was launched with, read the way its spawn reads it: where the
|
||||
* model param lives is registry data (`capabilities.model` names the param, the entry's
|
||||
* `legacyConfigField` the `<Mode>Config` object holding it, or the option bag itself for
|
||||
* claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell,
|
||||
* dsh) has none.
|
||||
*
|
||||
* @param mode the session's CLI id
|
||||
* @param bag the session's launch option bag (`model`, `codexConfig`, ...)
|
||||
*/
|
||||
export function launchModelFor(mode: string, bag: Record<string, unknown>): string | undefined {
|
||||
const entry = getCli(mode);
|
||||
const model = entry?.capabilities.model;
|
||||
if (!entry || !model || model.source === 'none') return undefined;
|
||||
const param = model.param ?? 'model';
|
||||
const key = entry.launch.legacyConfigAliases?.[param] ?? param;
|
||||
const value = legacyConfigForMode(mode, bag)?.[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted `displayModel` of a previous run, when it was a report from the CLI
|
||||
* itself: a restart shows it until the next report replaces it. A custom-endpoint or
|
||||
* launch answer is not restored, since the session derives those again by itself.
|
||||
*/
|
||||
export function restoredReportedModel(saved: unknown): ReportedModel | undefined {
|
||||
if (!saved || typeof saved !== 'object') return undefined;
|
||||
const { model, source } = saved as { model?: unknown; source?: unknown };
|
||||
if (source !== 'statusline' && source !== 'screen') return undefined;
|
||||
const name = sanitizeModelName(model);
|
||||
return name ? { model: name, source } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model a session header shows, and where it came from.
|
||||
*
|
||||
* @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.launchModel the model the session was launched with
|
||||
*/
|
||||
export function resolveDisplayModel(input: {
|
||||
customModelId?: string;
|
||||
reported?: ReportedModel | 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 launch = sanitizeModelName(input.launchModel);
|
||||
if (launch) return { model: launch, source: 'launch' };
|
||||
return undefined;
|
||||
}
|
||||
+101
-1
@@ -137,7 +137,16 @@ import {
|
||||
sanitizeAttachmentHistory,
|
||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||
} from './session-attachment-history.js';
|
||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
||||
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
|
||||
import {
|
||||
launchModelFor,
|
||||
readScreenModel,
|
||||
resolveDisplayModel,
|
||||
restoredReportedModel,
|
||||
sanitizeModelName,
|
||||
type ReportedModel,
|
||||
type ReportedModelSource,
|
||||
} from './session-display-model.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
@@ -550,6 +559,17 @@ export class Session extends EventEmitter {
|
||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
||||
/**
|
||||
* The newest model the running CLI reported for itself (its statusline, or its own
|
||||
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
|
||||
* (src/session-display-model.ts). Persisted through `toState()` and restored after a
|
||||
* restart, so an idle session keeps naming its model until the next report.
|
||||
*/
|
||||
private _reportedModel: ReportedModel | null = null;
|
||||
/** 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. */
|
||||
private _modelLineRows = 1;
|
||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
@@ -807,6 +827,8 @@ export class Session extends EventEmitter {
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||
paneExit?: PaneExit;
|
||||
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
|
||||
displayModel?: DisplayModel;
|
||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||
discoveredMuxSession?: boolean;
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
@@ -974,6 +996,7 @@ export class Session extends EventEmitter {
|
||||
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||
this.setPaneExit(config.paneExit);
|
||||
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||
// both the id and the saved parent come from disk.
|
||||
@@ -1858,6 +1881,7 @@ export class Session extends EventEmitter {
|
||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||
advisorModel: this._advisorModel,
|
||||
customModel: this.customModel,
|
||||
displayModel: this.displayModel,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
@@ -3134,9 +3158,85 @@ export class Session extends EventEmitter {
|
||||
this._lastPaneProbeWorking =
|
||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
||||
this._readWatching(text);
|
||||
this._readScreenModel(text);
|
||||
return this._lastPaneProbeWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the model the CLI's own footer names off the same capture, for a CLI whose
|
||||
* registry entry declares `capabilities.modelDetect`.
|
||||
*
|
||||
* Unlike `_readWatching`, a capture that could not be read, or a footer the pattern
|
||||
* does not find (a popup covering it, a footer turned off), KEEPS the last model. The
|
||||
* two are not symmetric: background work ends and its badge must go, while a model does
|
||||
* not stop running because something was drawn over the row that names it.
|
||||
*/
|
||||
private _readScreenModel(paneText: string | null): void {
|
||||
if (paneText === null) return;
|
||||
const pattern = this._modelLinePattern();
|
||||
if (!pattern) return;
|
||||
const model = readScreenModel(paneText, pattern, this._modelLineRows);
|
||||
if (model) this.noteReportedModel('screen', model);
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex reading this CLI's model off its footer, or null for a CLI that declares
|
||||
* none. Compiled once per session through `compileVersionRegex()` (null, never a throw,
|
||||
* for a pattern it refuses), like the working- and watching-line patterns.
|
||||
*/
|
||||
private _modelLinePattern(): RegExp | null {
|
||||
if (this._modelLineRe === undefined) {
|
||||
const detect = getCli(this.mode)?.capabilities.modelDetect;
|
||||
this._modelLineRe = detect ? compileVersionRegex(detect.screenLine) : null;
|
||||
this._modelLineRows = detect?.screenLines ?? 1;
|
||||
}
|
||||
return this._modelLineRe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a model the running CLI reported for itself: its statusline (claude's
|
||||
* exporter, via `POST /api/status-telemetry`) or its own footer. The newest report
|
||||
* wins whatever its source. An empty or unprintable report changes nothing.
|
||||
*
|
||||
* @returns true when the reported model changed (and `displayModelChanged` was emitted)
|
||||
*/
|
||||
noteReportedModel(source: ReportedModelSource, raw: unknown): boolean {
|
||||
const model = sanitizeModelName(raw);
|
||||
if (!model) return false;
|
||||
if (this._reportedModel?.model === model && this._reportedModel.source === source) return false;
|
||||
this._reportedModel = { model, source };
|
||||
// The status does not change with it, so it needs a broadcast (and a persist) of its own.
|
||||
this.emit('displayModelChanged');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model this session runs as far as the server knows, and where that came from:
|
||||
* the custom endpoint's model, else the newest report from the CLI, else the launch
|
||||
* model (src/session-display-model.ts). Undefined when none is known.
|
||||
*/
|
||||
get displayModel(): DisplayModel | undefined {
|
||||
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 `<Mode>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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the background-work chip off the same capture the working probe just took.
|
||||
*
|
||||
|
||||
@@ -641,6 +641,22 @@ export interface CustomModelSelection {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a session's {@link DisplayModel} came from (src/session-display-model.ts):
|
||||
* - `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.
|
||||
* - `launch`: what the session was launched with; nothing has reported since.
|
||||
*/
|
||||
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'launch';
|
||||
|
||||
/** The model a session runs as far as the server knows, for a session header. */
|
||||
export interface DisplayModel {
|
||||
/** Display text: sanitized (no control characters) and at most 64 characters. */
|
||||
model: string;
|
||||
source: DisplayModelSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full custom-model selection a session keeps: the public selection plus the
|
||||
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
||||
@@ -847,6 +863,15 @@ export interface SessionState {
|
||||
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
||||
*/
|
||||
customModel?: CustomModelSelection;
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
displayModel?: DisplayModel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
|
||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||
*
|
||||
* Also records the session's model (`model.display_name`) as its reported model, which
|
||||
* `session:updated` publishes as `displayModel` for the session headers.
|
||||
*
|
||||
* Returns a compact plain-text status string for the exporter to print as the
|
||||
* in-terminal footer (print-through) when it has no statusline of the user's
|
||||
* own to wrap. An unknown session gets an EMPTY body: the old brand-word
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||
import { getCli } from '../../config/cli-registry/index.js';
|
||||
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
// Last broadcast telemetry signature per session — the statusline fires on
|
||||
@@ -46,6 +50,15 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
||||
|
||||
const payload = data as RawStatuslinePayload | undefined;
|
||||
|
||||
// The model the CLI says it is running, for the session headers (displayModel). It
|
||||
// rides every render, so it follows an in-session `/model`; the session dedupes and
|
||||
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
|
||||
// report one here.
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
|
||||
session.noteReportedModel('statusline', payload?.model?.display_name);
|
||||
}
|
||||
|
||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||
// present and changed (the statusline fires on every assistant message).
|
||||
const telemetry = parseStatusTelemetry(payload);
|
||||
|
||||
@@ -3542,6 +3542,10 @@ export class WebServer extends EventEmitter {
|
||||
// pane-exit watcher's own tick replaces it with a first-hand
|
||||
// reading (not the stats collector — see `startPaneExitWatcher`).
|
||||
paneExit: savedState?.paneExit,
|
||||
// The model the CLI last reported (statusline or its own footer), so a
|
||||
// tile header keeps naming it until the next report; an idle claude
|
||||
// session re-renders its statusline only on its next turn.
|
||||
displayModel: savedState?.displayModel,
|
||||
// A record rebuilt from the socket has no provenance, so its
|
||||
// apparent locality is a guess (see `MuxSession.discovered`).
|
||||
discoveredMuxSession: muxSession.discovered,
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface SessionListenerRefs {
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
watchingChanged: () => void;
|
||||
displayModelChanged: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
@@ -275,6 +276,16 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Pushes the session state when the model the CLI reports changes (its statusline,
|
||||
* or its own footer), which the status does not reflect. Persisted too: a restart
|
||||
* restores the last reported model until the next report.
|
||||
*/
|
||||
displayModelChanged: () => {
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
// ─── Background Task Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `task:created` — new background task discovered */
|
||||
@@ -508,6 +519,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('working', refs.working);
|
||||
session.on('idle', refs.idle);
|
||||
session.on('watchingChanged', refs.watchingChanged);
|
||||
session.on('displayModelChanged', refs.displayModelChanged);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
session.on('taskCompleted', refs.taskCompleted);
|
||||
@@ -545,6 +557,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('watchingChanged', refs.watchingChanged);
|
||||
session.off('displayModelChanged', refs.displayModelChanged);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
session.off('taskCompleted', refs.taskCompleted);
|
||||
|
||||
@@ -383,6 +383,14 @@ export class MockSession extends EventEmitter {
|
||||
);
|
||||
restartCli = vi.fn(async () => true);
|
||||
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
|
||||
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
|
||||
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
|
||||
noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return false;
|
||||
if (this.reportedModel?.model === raw && this.reportedModel.source === source) return false;
|
||||
this.reportedModel = { model: raw, source };
|
||||
return true;
|
||||
});
|
||||
|
||||
/** Stub for sendInput */
|
||||
sendInput = vi.fn();
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
|
||||
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
|
||||
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
|
||||
* and 400 on a malformed body.
|
||||
* and 400 on a malformed body. Also the session's model (`displayModel`) the route
|
||||
* records off the same payload.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
|
||||
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
|
||||
const SID = 'test-session-1'; // default id created by createMockRouteContext
|
||||
|
||||
@@ -100,3 +102,56 @@ describe('POST /api/status-telemetry', () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/status-telemetry: the session model (displayModel)', () => {
|
||||
let h: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
|
||||
});
|
||||
|
||||
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
|
||||
|
||||
/** A real claude session in the route's map, so displayModel is the shipped resolver's. */
|
||||
function realSession(mode: 'claude' | 'codex', id: string): Session {
|
||||
const session = new Session({ id, workingDir: '/tmp', mode } as ConstructorParameters<typeof Session>[0]);
|
||||
(h.ctx.sessions as unknown as Map<string, unknown>).set(id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
it('records the model the statusline names, from the first render (no rate limits yet)', async () => {
|
||||
const session = realSession('claude', 'claude-1');
|
||||
const changed = vi.fn();
|
||||
session.on('displayModelChanged', changed);
|
||||
const res = await post({ sessionId: 'claude-1', data: { model: { display_name: 'Haiku 4.5' } } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('follows a model switch, and says nothing for an unchanged one', async () => {
|
||||
const session = realSession('claude', 'claude-2');
|
||||
const changed = vi.fn();
|
||||
session.on('displayModelChanged', changed);
|
||||
await post({ sessionId: 'claude-2', data: REAL });
|
||||
await post({ sessionId: 'claude-2', data: REAL });
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
await post({ sessionId: 'claude-2', data: { ...REAL, model: { display_name: 'Sonnet 4.6' } } });
|
||||
expect(session.toState().displayModel).toEqual({ model: 'Sonnet 4.6', source: 'statusline' });
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps a model name with markup as text, and a null name changes nothing', async () => {
|
||||
const session = realSession('claude', 'claude-3');
|
||||
await post({ sessionId: 'claude-3', data: { model: { display_name: '<b>Opus</b>' } } });
|
||||
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
|
||||
await post({ sessionId: 'claude-3', data: { model: { display_name: null } } });
|
||||
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
|
||||
});
|
||||
|
||||
it('takes no model from a CLI that has no statusline exporter', async () => {
|
||||
const session = realSession('codex', 'codex-1');
|
||||
await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } });
|
||||
expect(session.toState().displayModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Which model a session runs (`SessionState.displayModel`), for the tile grid's and the
|
||||
* split pane's headers: the pure resolver, the screen read with each CLI's registry
|
||||
* pattern, and the session that feeds them.
|
||||
*
|
||||
* The pane fixtures are verbatim `capture-pane -p` rows (trailing blanks trimmed) from
|
||||
* live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex
|
||||
* 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { compileVersionRegex, countCaptureGroups } from '../src/config/cli-registry/patterns.js';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import {
|
||||
MAX_DISPLAY_MODEL_CHARS,
|
||||
launchModelFor,
|
||||
readScreenModel,
|
||||
resolveDisplayModel,
|
||||
restoredReportedModel,
|
||||
sanitizeModelName,
|
||||
} from '../src/session-display-model.js';
|
||||
import { IDLE_SILENCE_MS } from '../src/session-activity.js';
|
||||
|
||||
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
|
||||
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
|
||||
const DSH_ROWS = detectOf('deepseek').screenLines;
|
||||
const CODEX = compileVersionRegex(detectOf('codex').screenLine)!;
|
||||
const CODEX_ROWS = detectOf('codex').screenLines;
|
||||
|
||||
const DSH_BORDER_TOP = '╭' + '─'.repeat(95) + '╮';
|
||||
const DSH_BORDER_BOTTOM = '╰' + '─'.repeat(95) + '╯';
|
||||
const DSH_COMPOSER = '❯' + ' '.repeat(95) + '⛶';
|
||||
/** The foot of a dsh-TUI pane: transcript, the composer's rounded box, the status line. */
|
||||
function dshPane(statusLine: string | null, transcript: string[] = []): string {
|
||||
return [
|
||||
' Tip: Footer compact on = one merged line; off = metric…',
|
||||
' Explore the uncharted!',
|
||||
' ▶ (Ctrl+P to expand) Context loaded · System prompt 16 sections · Runtime context 2 items · …',
|
||||
...transcript,
|
||||
DSH_BORDER_TOP,
|
||||
DSH_COMPOSER,
|
||||
DSH_BORDER_BOTTOM,
|
||||
...(statusLine === null ? [] : [statusLine]),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
const DSH_LIVE = dshPane(' qwen3.8-27b · medium · th-scratch');
|
||||
|
||||
/** The foot of a codex pane: transcript, composer, then the status line on the last row. */
|
||||
function codexPane(statusLine: string | null, transcript: string[] = []): string {
|
||||
return [
|
||||
'│ directory: ~/codeman-cases/th-scratch │',
|
||||
'│ permissions: YOLO mode │',
|
||||
'╰───────────────────────────────────────────────╯',
|
||||
' Tip: New For a limited time, Codex is included in your',
|
||||
' plan for free – let’s build together.',
|
||||
...transcript,
|
||||
'› Explain this codebase',
|
||||
...(statusLine === null ? [] : [statusLine]),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
const CODEX_LIVE = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch');
|
||||
const CODEX_154 = codexPane(
|
||||
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left'
|
||||
);
|
||||
|
||||
describe('the registry patterns', () => {
|
||||
it('compile through compileVersionRegex() with exactly one capture group', () => {
|
||||
for (const mode of ['deepseek', 'codex']) {
|
||||
const { screenLine } = detectOf(mode);
|
||||
expect(compileVersionRegex(screenLine), mode).not.toBeNull();
|
||||
expect(countCaptureGroups(screenLine), mode).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('are declared only where a footer was measured (claude reports through its statusline)', () => {
|
||||
expect(getCli('claude')!.capabilities.modelDetect).toBeUndefined();
|
||||
expect(getCli('shell')!.capabilities.modelDetect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the schema refuses a pattern with no capture group, two of them, or one it will not run', () => {
|
||||
const codex = getCli('codex')!;
|
||||
const withDetect = (modelDetect: unknown) =>
|
||||
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
|
||||
expect(withDetect({ screenLine: '^ {2}([a-z]+) · ' })).toBe(true);
|
||||
expect(withDetect({ screenLine: '^ {2}[a-z]+ · ' })).toBe(false);
|
||||
expect(withDetect({ screenLine: '^ {2}([a-z]+) (high) · ' })).toBe(false);
|
||||
expect(withDetect({ screenLine: '(a+)+$' })).toBe(false);
|
||||
expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it('countCaptureGroups counts named groups and ignores non-capturing ones', () => {
|
||||
expect(countCaptureGroups('a(?:b)(?<m>c)')).toBe(1);
|
||||
expect(countCaptureGroups('(a)(b)')).toBe(2);
|
||||
expect(countCaptureGroups('abc')).toBe(0);
|
||||
expect(countCaptureGroups('(')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readScreenModel', () => {
|
||||
it("reads dsh's model off the row under its composer", () => {
|
||||
expect(readScreenModel(DSH_LIVE, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
|
||||
// A footer with the model alone, and one with a row below it (the activity line).
|
||||
expect(readScreenModel(dshPane(' deepseek-v4-flash'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash');
|
||||
expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch') + ' ⠋ Thinking… 3s\n', DSH, DSH_ROWS)).toBe(
|
||||
'qwen3.8-27b'
|
||||
);
|
||||
});
|
||||
|
||||
it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => {
|
||||
expect(readScreenModel(CODEX_LIVE, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
|
||||
expect(readScreenModel(CODEX_154, CODEX, CODEX_ROWS)).toBe('gpt-5.6-sol');
|
||||
});
|
||||
|
||||
it('never takes a transcript line shaped like the footer', () => {
|
||||
// The agent printed a line exactly like each CLI's footer, and the real footer is
|
||||
// hidden (a dsh status bar switched off; a codex popup over its last row). The
|
||||
// transcript sits above the composer, so neither may be read as the model.
|
||||
const dshForged = dshPane(null, [DSH_BORDER_BOTTOM, ' evil-model · medium · th-scratch']);
|
||||
expect(readScreenModel(dshForged, DSH, DSH_ROWS)).toBeUndefined();
|
||||
const codexForged = codexPane(null, [' evil-model high · ~/codeman-cases/th-scratch']);
|
||||
// A codex screen whose last row is the composer: nothing to read.
|
||||
expect(readScreenModel(codexForged, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||
// With the real footer back, the real model wins over the forged line above it.
|
||||
const dshBoth = dshPane(' qwen3.8-27b · medium · th-scratch', [
|
||||
DSH_BORDER_BOTTOM,
|
||||
' evil-model · medium · th-scratch',
|
||||
]);
|
||||
expect(readScreenModel(dshBoth, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
|
||||
const codexBoth = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch', [
|
||||
' evil-model high · ~/codeman-cases/th-scratch',
|
||||
]);
|
||||
expect(readScreenModel(codexBoth, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
|
||||
});
|
||||
|
||||
it('does not read a popup under the composer as a model', () => {
|
||||
const slash = dshPane(' /model Switch the model route');
|
||||
expect(readScreenModel(slash, DSH, DSH_ROWS)).toBeUndefined();
|
||||
const codexSlash = codexPane(' /model choose what model and reasoning effort to use');
|
||||
expect(readScreenModel(codexSlash, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||
// A last row without codex's `<model> <effort> ·` shape names no model, even one
|
||||
// whose first word could pass for a model id.
|
||||
const codexNoModel = codexPane(' default · ~/codeman-cases/th-scratch');
|
||||
expect(readScreenModel(codexNoModel, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('says nothing about an empty or unreadable frame', () => {
|
||||
expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined();
|
||||
expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeModelName', () => {
|
||||
it('keeps markup as text: sanitizing is not escaping, the browser renders text', () => {
|
||||
expect(sanitizeModelName('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('drops escape sequences and control characters, and collapses whitespace', () => {
|
||||
expect(sanitizeModelName('\x1b[31mOpus\x1b[0m\t 4.8\n')).toBe('Opus 4.8');
|
||||
expect(sanitizeModelName('\x00\x07')).toBeUndefined();
|
||||
expect(sanitizeModelName(' ')).toBeUndefined();
|
||||
expect(sanitizeModelName(42)).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`caps the name at ${MAX_DISPLAY_MODEL_CHARS} characters`, () => {
|
||||
expect(sanitizeModelName('m'.repeat(500))).toHaveLength(MAX_DISPLAY_MODEL_CHARS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDisplayModel', () => {
|
||||
const reported = { model: 'Sonnet 4.6', source: 'statusline' as const };
|
||||
|
||||
it('the custom endpoint wins, then the newest report, then the launch model', () => {
|
||||
expect(resolveDisplayModel({ customModelId: 'qwen3.8-27b', reported, launchModel: 'opus' })).toEqual({
|
||||
model: 'qwen3.8-27b',
|
||||
source: 'custom-endpoint',
|
||||
});
|
||||
expect(resolveDisplayModel({ reported, launchModel: 'opus' })).toEqual(reported);
|
||||
expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' });
|
||||
});
|
||||
|
||||
it('knows nothing when nothing is known: no placeholder', () => {
|
||||
expect(resolveDisplayModel({})).toBeUndefined();
|
||||
expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoredReportedModel', () => {
|
||||
it('restores what the CLI reported, never a derived answer', () => {
|
||||
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'screen' })).toEqual({
|
||||
model: 'qwen3.8-27b',
|
||||
source: 'screen',
|
||||
});
|
||||
expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline');
|
||||
expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined();
|
||||
expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined();
|
||||
expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined();
|
||||
expect(restoredReportedModel('screen')).toBeUndefined();
|
||||
expect(restoredReportedModel(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchModelFor', () => {
|
||||
it("reads the model param where each CLI's registry entry keeps it", () => {
|
||||
expect(launchModelFor('claude', { model: 'haiku' })).toBe('haiku');
|
||||
expect(launchModelFor('codex', { codexConfig: { model: 'gpt-5.5' } })).toBe('gpt-5.5');
|
||||
expect(launchModelFor('grok', { grokConfig: { model: 'grok-code-fast' } })).toBe('grok-code-fast');
|
||||
// Another CLI's config, or a top-level model, is not this CLI's launch model.
|
||||
expect(launchModelFor('codex', { model: 'opus', grokConfig: { model: 'x' } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('has none for a CLI whose model is not a launch param', () => {
|
||||
expect(launchModelFor('deepseek', { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } })).toBeUndefined();
|
||||
expect(launchModelFor('shell', { model: 'opus' })).toBeUndefined();
|
||||
expect(launchModelFor('no-such-cli', { model: 'opus' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a session', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
type Internals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
_detectInteractiveActivity(data: string): void;
|
||||
_customModel?: { endpointId: string; modelId: string; envKeys: string[] };
|
||||
};
|
||||
|
||||
function withFakePane(mode: string, screen: () => string | null, extra: Record<string, unknown> = {}): Session {
|
||||
const mux = { isAvailable: () => true, capturePaneText: () => screen() } as unknown as NonNullable<
|
||||
ConstructorParameters<typeof Session>[0]
|
||||
>['mux'];
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode,
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
...extra,
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
}
|
||||
|
||||
/** A composer repaint (arms the idle confirmation), then quiet: the probe reads the screen. */
|
||||
function settle(session: Session, glyph: string): void {
|
||||
const internals = session as unknown as Internals;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const frame = `\x1b[31;1H${glyph}\xa0`;
|
||||
internals._handleTerminalOutput(frame);
|
||||
internals._detectInteractiveActivity(frame);
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
}
|
||||
|
||||
it('publishes the model its footer names, and follows the footer when it changes', () => {
|
||||
vi.useFakeTimers();
|
||||
let screen = DSH_LIVE;
|
||||
const session = withFakePane('deepseek', () => screen);
|
||||
const changed = vi.fn();
|
||||
session.on('displayModelChanged', changed);
|
||||
expect(session.toState().displayModel).toBeUndefined();
|
||||
settle(session, '❯');
|
||||
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
// The same footer again: nothing new to say.
|
||||
settle(session, '❯');
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
// An in-session switch redraws the footer.
|
||||
screen = dshPane(' deepseek-v4-flash · high · th-scratch');
|
||||
settle(session, '❯');
|
||||
expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the last model when the footer cannot be read', () => {
|
||||
vi.useFakeTimers();
|
||||
let screen: string | null = CODEX_LIVE;
|
||||
const session = withFakePane('codex', () => screen);
|
||||
settle(session, '›');
|
||||
expect(session.toState().displayModel?.model).toBe('gpt-5.6-terra');
|
||||
screen = codexPane(' /model choose what model and reasoning effort to use');
|
||||
settle(session, '›');
|
||||
screen = null;
|
||||
settle(session, '›');
|
||||
expect(session.toState().displayModel).toEqual({ model: 'gpt-5.6-terra', source: 'screen' });
|
||||
});
|
||||
|
||||
it('a CLI without a footer pattern never reads one (a claude pane says nothing)', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane('claude', () => '❯ \n Haiku 4.5 in:0 out:0\n');
|
||||
settle(session, '❯');
|
||||
expect(session.toState().displayModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows the launch model until the CLI reports, and a custom endpoint over both', () => {
|
||||
const session = withFakePane('claude', () => null, { model: 'haiku' });
|
||||
expect(session.toState().displayModel).toEqual({ model: 'haiku', source: 'launch' });
|
||||
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(true);
|
||||
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
|
||||
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(false);
|
||||
expect(session.noteReportedModel('statusline', '')).toBe(false);
|
||||
(session as unknown as Internals)._customModel = { endpointId: 'e', modelId: 'qwen3.8-27b', envKeys: [] };
|
||||
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'custom-endpoint' });
|
||||
(session as unknown as Internals)._customModel = undefined;
|
||||
expect(session.toState().displayModel?.model).toBe('Haiku 4.5');
|
||||
});
|
||||
|
||||
it("an external CLI's launch model is its own config's, never the inert top-level one", () => {
|
||||
const codex = withFakePane('codex', () => null, { model: 'opus', codexConfig: { model: 'gpt-5.5' } });
|
||||
expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' });
|
||||
const dsh = withFakePane('deepseek', () => null, { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } });
|
||||
expect(dsh.toState().displayModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores a reported model after a restart, until the next report replaces it', () => {
|
||||
const session = withFakePane('deepseek', () => null, {
|
||||
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
|
||||
});
|
||||
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
|
||||
session.noteReportedModel('screen', 'deepseek-v4-flash');
|
||||
expect(session.toState().displayModel?.model).toBe('deepseek-v4-flash');
|
||||
// A launch answer from the previous run is derived again, not restored.
|
||||
const claude = withFakePane('claude', () => null, { displayModel: { model: 'opus', source: 'launch' } });
|
||||
expect(claude.toState().displayModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
||||
import {
|
||||
attachSessionListeners,
|
||||
createSessionListeners,
|
||||
detachSessionListeners,
|
||||
} from '../src/web/session-listener-wiring.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
|
||||
describe('session listener wiring', () => {
|
||||
@@ -37,6 +41,27 @@ describe('session listener wiring', () => {
|
||||
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
|
||||
});
|
||||
|
||||
it('pushes and persists the session state when the reported model changes', () => {
|
||||
// A model switch changes nothing the status reflects, so without its own broadcast
|
||||
// every header would keep naming the old model; persisting it is what lets a restart
|
||||
// restore it.
|
||||
const session = new Session({ id: 'wiring-model-test', workingDir: '/tmp', mode: 'claude' });
|
||||
const broadcastSessionStateDebounced = vi.fn();
|
||||
const persistSessionState = vi.fn();
|
||||
const deps = { broadcastSessionStateDebounced, persistSessionState } as unknown as Parameters<
|
||||
typeof createSessionListeners
|
||||
>[1];
|
||||
|
||||
const refs = createSessionListeners(session, deps);
|
||||
attachSessionListeners(session, refs);
|
||||
session.noteReportedModel('statusline', 'Sonnet 4.6');
|
||||
|
||||
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-model-test');
|
||||
expect(persistSessionState).toHaveBeenCalledWith(session);
|
||||
detachSessionListeners(session, refs);
|
||||
expect(session.listenerCount('displayModelChanged')).toBe(0);
|
||||
});
|
||||
|
||||
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user