mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +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:
@@ -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
|
||||
* 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
@@ -91,6 +94,21 @@ describe('the registry patterns', () => {
|
||||
expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it('dsh also names a config reader for while its screen names no model', () => {
|
||||
expect(detectOf('deepseek').configResolver).toBe('deepseek-route');
|
||||
expect(detectOf('codex').configResolver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the schema takes a known config reader alone, and refuses an empty or unknown one', () => {
|
||||
const codex = getCli('codex')!;
|
||||
const withDetect = (modelDetect: unknown) =>
|
||||
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
|
||||
expect(withDetect({ configResolver: 'deepseek-route' })).toBe(true);
|
||||
expect(withDetect({ configResolver: 'read-anything' })).toBe(false);
|
||||
expect(withDetect({})).toBe(false);
|
||||
expect(withDetect({ configResolver: 'deepseek-route', screenLines: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('countCaptureGroups counts named groups and ignores non-capturing ones', () => {
|
||||
expect(countCaptureGroups('a(?:b)(?<m>c)')).toBe(1);
|
||||
expect(countCaptureGroups('(a)(b)')).toBe(2);
|
||||
@@ -181,6 +199,16 @@ describe('resolveDisplayModel', () => {
|
||||
expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' });
|
||||
});
|
||||
|
||||
it("the config ranks below the CLI's own report and above the launch model", () => {
|
||||
expect(resolveDisplayModel({ reported, configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual(reported);
|
||||
expect(resolveDisplayModel({ configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual({
|
||||
model: 'qwen3.8-27b',
|
||||
source: 'config',
|
||||
});
|
||||
expect(resolveDisplayModel({ customModelId: 'm', configModel: 'qwen3.8-27b' })?.source).toBe('custom-endpoint');
|
||||
expect(resolveDisplayModel({ configModel: null, launchModel: 'opus' })?.source).toBe('launch');
|
||||
});
|
||||
|
||||
it('knows nothing when nothing is known: no placeholder', () => {
|
||||
expect(resolveDisplayModel({})).toBeUndefined();
|
||||
expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined();
|
||||
@@ -195,6 +223,7 @@ describe('restoredReportedModel', () => {
|
||||
});
|
||||
expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline');
|
||||
expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined();
|
||||
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'config' })).toBeUndefined();
|
||||
expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined();
|
||||
expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined();
|
||||
expect(restoredReportedModel('screen')).toBeUndefined();
|
||||
@@ -326,3 +355,37 @@ describe('a session', () => {
|
||||
expect(claude.toState().displayModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dsh session over a fixture dsh home (end to end, no mocks)', () => {
|
||||
it('names the route its profile pins until the screen names one, then the screen', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-e2e-'));
|
||||
try {
|
||||
const home = join(root, 'dsh');
|
||||
mkdirSync(join(home, 'profiles', 'dsh-tui'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'profiles', 'dsh-tui', 'package.json'),
|
||||
JSON.stringify({ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui'] } } })
|
||||
);
|
||||
writeFileSync(
|
||||
join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'),
|
||||
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n'
|
||||
);
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'deepseek',
|
||||
deepSeekConfig: { profile: 'dsh-tui' },
|
||||
envOverrides: { DSH_HOME: home },
|
||||
} as ConstructorParameters<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