fix(cli-registry): codex launch defaults as registry data, ultra footer, schema doc defaults

- Codex footer model detection (c28): the modelDetect.screenLine effort
  alternation is now built from CODEX_REASONING_EFFORTS plus 'default', so
  'ultra' (offered by the codexReasoningEffort App Setting and codex's own
  /model picker) is read and the launch enum and the footer reader cannot
  drift again. Still one capture group, 125 characters, no new quantifier.
  New session-display-model case loops every effort level, ultra included.

- No CLI-id branching for launch defaults (c27): the two mode === 'codex'
  branches the synced codex model/effort defaults added to the create and
  quick-start routes are replaced by a registry capability,
  capabilities.launchDefaults (launch param -> settings key, values from a
  closed enum), declared on the codex entry only. The resolver moved from
  web/codex-launch-defaults.ts to web/launch-defaults.ts as
  applyLaunchDefaults(mode, configs, customEndpoint), filling the entry's
  legacyConfigField object through legacyConfigAliases, still re-validating
  with SettingsUpdateSchema and never overwriting a caller's value. The
  route exclusions are unchanged (create: not remote; quick-start: not
  remote, not Docker, not a custom model endpoint), and quick-start still
  derives the session model from a bag without ompConfig, as before.
  schema.ts refuses an undeclared param, an unknown settings key, an empty
  map, and launchDefaults on an entry with no legacyConfigField.

- The no-id-branching guard now carries an exact occurrence count per
  allowlisted key, so a new copy of an already approved expression fails
  instead of riding the old approval, with a synthetic anti-vacuity case.

- SettingsUpdateSchema JSDoc (c21/c29): 'classic' is the tabArrangement
  default and 'compact' the headerStatsStyle default, matching the
  resolvers and the pre-paint script; state/case/ledger are marked opt-in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:35:56 +02:00
parent 82c87f56d1
commit ecd577157b
12 changed files with 456 additions and 135 deletions
+174 -51
View File
@@ -33,6 +33,13 @@
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
* `config/cli-registry/profiles.ts` as a named profile.
*
* ⚠️ Each entry also carries how many times its expression occurs in that file, compared
* EXACTLY. The key is only `<file>::<expression>`, so without a count an approved branch
* approved every later copy of the same text in the same file: the codex launch defaults
* added two more `mode === 'codex'` branches to session-routes.ts and passed silently,
* because the legacy-plumbing entry already covered that string. A new copy now fails as
* an unapproved branch would, and a removed one fails as stale until the count drops.
*
* Port: none (pure static analysis).
*/
@@ -67,47 +74,67 @@ const EXEMPT_FILES = new Set(
].map((p) => p.split('/').join(sep))
);
/** One approved branch: how many copies of it the file holds, and why it is not a capability. */
interface Allowance {
count: number;
reason: string;
}
const allow = (count: number, reason: string): Allowance => ({ count, reason });
/**
* Specific surviving branches, each with the reason it is not a capability.
* Specific surviving branches, each with the reason it is not a capability and the exact
* number of times the expression occurs in that file (see the header).
* Keyed `<relative path>::<the matched expression>`.
*/
const ALLOWED_BRANCHES: Record<string, string> = {
const ALLOWED_BRANCHES: Record<string, Allowance> = {
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
"web/routes/session-routes.ts::mode === 'opencode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'codex'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'gemini'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'":
"tmux-manager.ts::mode === 'claude'": allow(
2,
"claude's remote pane command carries per-session permission flags, and its docker form is " +
'`--session-id … || resume`; neither fits a static overlays.command string',
"tmux-manager.ts::mode === 'omp'":
'`--session-id … || resume`; neither fits a static overlays.command string'
),
"tmux-manager.ts::mode === 'omp'": allow(
1,
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode'
),
// --- Per-CLI prose and launch handling not yet generalised ---
"web/session-wait-registry.ts::mode === 'deepseek'":
'an error message explaining why THIS mode in particular will never deliver a stop signal',
"web/routes/approval-routes.ts::mode === 'deepseek'":
'the DeepSeek status bridge is the only non-claude source of approval items',
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
"web/session-wait-registry.ts::mode === 'deepseek'": allow(
1,
'an error message explaining why THIS mode in particular will never deliver a stop signal'
),
"web/routes/approval-routes.ts::mode === 'deepseek'": allow(
1,
'the DeepSeek status bridge is the only non-claude source of approval items'
),
"cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'),
"cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'),
"web/routes/session-routes.ts::mode === 'claude'": allow(
2,
'docker case bookkeeping keyed on the claude conversation id'
),
"cli.ts::mode === 'shell'": allow(1, 'a CLI-table label, not behaviour'),
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
//
@@ -121,43 +148,58 @@ const ALLOWED_BRANCHES: Record<string, string> = {
// there, the shared predicate silently widened both to a mode with no transcript to read.
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
// capability here would be actively wrong.
"web/routes/readmymind-routes.ts::mode !== 'claude'":
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
"web/server.ts::mode !== 'claude'":
"web/routes/readmymind-routes.ts::mode !== 'claude'": allow(
1,
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts'
),
"web/server.ts::mode !== 'claude'": allow(
2,
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)'
),
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
// all; the resume one would need a "resumable from a claude transcript" field that nothing
// else would read.
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
"tui/tui-app.ts::mode !== 'claude'": allow(
1,
'TUI resume builds a claude --resume; claude-transcript-only by construction'
),
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
// capability; deliberately not invented here.
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
'push buttons withheld where the answer route refuses keystrokes',
"web/routes/hook-event-routes.ts::mode !== 'deepseek'": allow(
1,
'push buttons withheld where the answer route refuses keystrokes'
),
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
"web/routes/session-routes.ts::mode !== 'omp'": allow(
2,
'legacy <Mode>Config plumbing (resolveOmpConfigForCreate), and one link of the scaffolded-case hooks ' +
'chain (see the opencode entry below)'
),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
"web/routes/session-routes.ts::mode !== 'opencode'":
"web/routes/session-routes.ts::mode !== 'opencode'": allow(
2,
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
'so any capability form would change behaviour — see PR discussion',
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
'so any capability form would change behaviour — see PR discussion'
),
"web/routes/session-routes.ts::mode !== 'codex'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'gemini'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
@@ -246,6 +288,43 @@ function scan(): { findings: Finding[]; filesScanned: number } {
const { findings, filesScanned } = scan();
interface CountMismatch {
key: string;
allowed: number;
found: number;
lines: string[];
}
/**
* Allowlisted keys whose occurrence count differs from the approved one: `grown` holds the
* keys with MORE copies than approved (a new branch riding an old approval), `shrunk` the
* ones with fewer (a stale approval that would let the next copy back in unseen).
* Unallowlisted keys are not this function's business; the offenders check covers them.
*/
function countMismatches(
found: Finding[],
allowed: Record<string, Allowance>
): { grown: CountMismatch[]; shrunk: CountMismatch[] } {
const byKey = new Map<string, Finding[]>();
for (const f of found) byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]);
const grown: CountMismatch[] = [];
const shrunk: CountMismatch[] = [];
for (const [key, allowance] of Object.entries(allowed)) {
const hits = byKey.get(key) ?? [];
const mismatch = {
key,
allowed: allowance.count,
found: hits.length,
lines: hits.map((f) => `${f.file}:${f.line}`),
};
if (hits.length > allowance.count) grown.push(mismatch);
else if (hits.length < allowance.count) shrunk.push(mismatch);
}
return { grown, shrunk };
}
const { grown, shrunk } = countMismatches(findings, ALLOWED_BRANCHES);
describe('no CLI-id branching outside the stock catalog', () => {
it('scans a meaningful number of source files (sanity)', () => {
// If this collapses toward zero the walker or the exemption list drifted and every
@@ -293,12 +372,56 @@ describe('no CLI-id branching outside the stock catalog', () => {
).toEqual([]);
});
it('has no new copy of an allowlisted branch', () => {
// An approval covers the copies that were reviewed, not every later line that happens
// to spell the same expression in the same file.
const detail = grown
.map((m) => ` ${m.key}: ${m.found} found, ${m.allowed} approved\n ${m.lines.join('\n ')}`)
.join('\n');
expect(
grown,
grown.length === 0
? ''
: `Found more copies of an allowlisted CLI-id branch than were approved:\n${detail}\n\n` +
'Express the new copy as a CliCapabilities field (or a named profile) rather than raising ' +
"the count. Raise it only for another branch of the SAME kind, and read this file's header first."
).toEqual([]);
});
it('has no stale allowlist entries', () => {
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
// person to reintroduce that exact branch would sail straight through.
const present = new Set(findings.map((f) => f.key));
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
// person to reintroduce that exact branch would sail straight through. The same holds
// for an approved count above what the file still has.
const stale = shrunk.map((m) => `${m.key}: ${m.found} found, ${m.allowed} approved`);
expect(
stale,
`ALLOWED_BRANCHES entries no longer (fully) present; delete them or lower the count:\n ${stale.join('\n ')}`
).toEqual([]);
});
it('counts copies per key, so one extra copy of an approved branch fails (anti-vacuity)', () => {
const at = (line: number): Finding => ({
file: 'web/example.ts',
expression: "mode === 'codex'",
line,
key: "web/example.ts::mode === 'codex'",
});
const approved = { "web/example.ts::mode === 'codex'": allow(1, 'synthetic') };
expect(countMismatches([at(10)], approved)).toEqual({ grown: [], shrunk: [] });
const extra = countMismatches([at(10), at(42)], approved);
expect(extra.grown).toEqual([
{
key: "web/example.ts::mode === 'codex'",
allowed: 1,
found: 2,
lines: ['web/example.ts:10', 'web/example.ts:42'],
},
]);
expect(countMismatches([], approved).shrunk.map((m) => m.key)).toEqual(["web/example.ts::mode === 'codex'"]);
// Every live entry carries a positive whole count, or the comparison means nothing.
for (const [key, { count }] of Object.entries(ALLOWED_BRANCHES)) {
expect(Number.isInteger(count) && count > 0, key).toBe(true);
}
});
});
+51
View File
@@ -291,6 +291,57 @@ describe('cross-field integrity', () => {
});
});
describe('capabilities.launchDefaults', () => {
/** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */
function codexWith(mutate: (entry: Record<string, unknown>) => void): boolean {
const entry = baseEntry('codex');
mutate(entry);
return CliEntrySchema.safeParse(entry).success;
}
const setDefaults = (value: unknown) => (e: Record<string, unknown>) => {
(e.capabilities as Record<string, unknown>).launchDefaults = value;
};
it('ships on codex alone, keyed by launch param', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id);
expect(declaring).toEqual(['codex']);
expect(codexWith(() => {})).toBe(true);
});
it('rejects a param the entry never declared', () => {
// Filled into the config object and then read by nothing: a silent no-op, like a
// privilegedParams clamp naming the wrong param.
expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false);
});
it('rejects a settings key outside the closed list', () => {
// An override must not be able to pour an arbitrary setting onto a command line.
expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false);
expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false);
});
it('rejects an empty map', () => {
expect(codexWith(setDefaults({}))).toBe(false);
});
it('refuses an entry with no legacyConfigField to fill', () => {
// Without one the params are read off the request body itself, where `model` is
// claude's per-session field, not this CLI's.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
})
).toBe(false);
// The same entry without launch defaults is fine: the refusal is about the pair.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
delete (e.capabilities as Record<string, unknown>).launchDefaults;
})
).toBe(true);
});
});
describe('the env allowlist cannot be widened by config', () => {
it('requires a prefix to end with an underscore', () => {
expectRejected((e) => {
@@ -7,18 +7,26 @@ import { createRouteTestHarness, type RouteTestHarness } from './_route-test-uti
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js';
import { applyLaunchDefaults } from '../../src/web/launch-defaults.js';
import { buildCodexCommand } from '../../src/tmux-manager.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { SettingsUpdateSchema } from '../../src/web/schemas.js';
import { getCli } from '../../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
import type { CodexConfig } from '../../src/types.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
/** The codexConfig a launch gets from the synced defaults alone (the caller sent `config`). */
async function resolveCodexDefaults(config: CodexConfig | undefined, customEndpoint = false) {
return (await applyLaunchDefaults('codex', { codexConfig: config }, customEndpoint)).codexConfig;
}
describe('Codex launch defaults', () => {
let harness: RouteTestHarness;
const workingDir = join(homedir(), 'codex-default-test');
@@ -76,14 +84,45 @@ describe('Codex launch defaults', () => {
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
]) {
await writeFile(SETTINGS_PATH, JSON.stringify(settings));
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex');
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe('codex');
}
});
it('keeps defaults out of custom endpoint launches', async () => {
const config = { model: 'local-model', animations: false };
expect(await resolveCodexLaunchDefaults(config, true)).toBe(config);
expect(await resolveCodexDefaults(config, true)).toBe(config);
});
it('is driven by the registry: codex declares the defaults, and a CLI without them is untouched', async () => {
// The routes call applyLaunchDefaults for every mode and never ask which CLI it is, so
// what codex gets is exactly what its entry declares.
expect(getCli('codex')!.capabilities.launchDefaults).toEqual({
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
});
for (const entry of STOCK_CLIS) {
if ((entry.id as string) === 'codex') continue;
expect(entry.capabilities.launchDefaults, entry.id).toBeUndefined();
const configs = { codexConfig: undefined, geminiConfig: { model: 'g' }, piConfig: undefined };
expect(await applyLaunchDefaults(entry.id, configs), entry.id).toBe(configs);
}
// Only the entry's own config object is filled; the rest of the bag passes through.
const bag = { codexConfig: { animations: false }, geminiConfig: { model: 'g' }, name: 'n' };
const filled = await applyLaunchDefaults('codex', bag);
expect(filled).toEqual({
codexConfig: { animations: false, model: 'gpt-6.1', reasoningEffort: 'high' },
geminiConfig: { model: 'g' },
name: 'n',
});
expect(filled.geminiConfig).toBe(bag.geminiConfig);
expect(bag.codexConfig).toEqual({ animations: false });
// A field the caller sent is never overwritten, one at a time.
expect(await resolveCodexDefaults({ model: 'mine' })).toEqual({ model: 'mine', reasoningEffort: 'high' });
expect(await resolveCodexDefaults({ reasoningEffort: 'low' })).toEqual({
model: 'gpt-6.1',
reasoningEffort: 'low',
});
});
it('does not record unused defaults for a Docker quick-start', async () => {
@@ -150,7 +189,7 @@ describe('Codex launch defaults', () => {
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
} finally {
await system.app.close();
}
+15
View File
@@ -25,6 +25,7 @@ import {
sanitizeModelName,
} from '../src/session-display-model.js';
import { IDLE_SILENCE_MS } from '../src/session-activity.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
@@ -223,6 +224,20 @@ describe('readScreenModel', () => {
expect(readScreenModel(codexPane(null, [], ' ← for agents · ? for shortcuts'), CODEX, CODEX_ROWS)).toBeUndefined();
});
it("reads codex's model at every reasoning effort Codeman can launch it with, ultra included", () => {
// `ultra` is offered by the codexReasoningEffort App Setting and codex's own /model
// picker; a hand-kept effort list in the pattern once left it out, so an ultra
// session's header never named its model. The footer prints the lowercase level.
const ultra = codexPane(' GPT-6-Astra ultra · ~/codeman-cases/testcase', [], ' ? for shortcuts');
expect(readScreenModel(ultra, CODEX, CODEX_ROWS)).toBe('GPT-6-Astra');
for (const effort of [...CODEX_REASONING_EFFORTS, 'default']) {
const atRest = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`, [], ' ? for shortcuts');
expect(readScreenModel(atRest, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
const typing = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`);
expect(readScreenModel(typing, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
}
});
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