Merge remote-tracking branch 'origin/master' into relprep/pr-614

This commit is contained in:
Codeman maintainer committed 2026-11-06 02:44:43 +01:00
commit 0d42fdc0cf
635 files changed
+97889 -3794

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
/**
* @fileoverview Accessory keys that keep the keyboard up must not take focus on
* press. A tap used to blur the terminal's textarea and the click handler then
* refocused it, so every arrow/Tab/Esc tap started a keyboard dismissal. The bar
* now cancels the mousedown default for exactly those keys (and the first,
* arming tap of clear/compact); every other key keeps the old behavior.
*
* Loaded with `vm` against a minimal fake DOM, like the other accessory-bar tests.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
type Listener = (e: unknown) => void;
type Bar = {
init(): void;
_confirmAction: string | null;
syncReadMyMind(): void;
syncCodexKeys(): void;
_syncComposerDraftIndicator(): void;
};
function loadBar() {
const listeners: Record<string, Listener> = {};
const element = {
className: '',
innerHTML: '',
addEventListener: (type: string, fn: Listener) => {
listeners[type] = fn;
},
};
const context = vm.createContext({
console,
document: { createElement: () => element, querySelector: () => null },
MobileDetection: { isTouchDevice: () => true },
localStorage: { getItem: () => null, setItem: () => {} },
});
vm.runInContext(
`${readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8')}\nglobalThis.__bar = KeyboardAccessoryBar;`,
context
);
const bar = (context as unknown as { __bar: Bar }).__bar;
bar.syncReadMyMind = () => {};
bar.syncCodexKeys = () => {};
bar._syncComposerDraftIndicator = () => {};
bar.init();
// `null` is a press on the bar itself (padding, a gap): closest() finds no key.
const press = (action: string | null) => {
const preventDefault = vi.fn();
listeners.mousedown({
target: { closest: () => (action === null ? null : { dataset: { action } }) },
preventDefault,
});
return preventDefault.mock.calls.length > 0;
};
return { bar, press };
}
describe('accessory keys keep the terminal focused on press', () => {
it('cancels the press default for keys that leave the keyboard up', () => {
const { press } = loadBar();
for (const action of [
'scroll-up',
'scroll-down',
'arrow-left',
'arrow-right',
'tab',
'shift-tab',
'shift-left',
'shift-right',
'ctrl',
'ctrl-o',
'opt-enter',
'esc',
'effort-max',
'clear-input',
]) {
expect(press(action), action).toBe(true);
}
});
it('leaves keys that move focus or dismiss the keyboard alone', () => {
const { press } = loadBar();
for (const action of ['compose', 'readmymind', 'pick-path', 'paste', 'dismiss', 'init']) {
expect(press(action), action).toBe(false);
}
});
it('cancels only the arming tap of clear/compact, not the confirming one', () => {
const { bar, press } = loadBar();
bar._confirmAction = null;
expect(press('clear')).toBe(true);
bar._confirmAction = 'clear';
expect(press('clear')).toBe(false);
expect(press('compact')).toBe(true);
bar._confirmAction = 'compact';
expect(press('compact')).toBe(false);
});
it('ignores a press outside a key', () => {
const { press } = loadBar();
expect(press(null)).toBe(false);
});
});
+4 -1
View File
@@ -189,10 +189,13 @@ describe('admin case-folder API', () => {
expect(sl.status).toBe(400);
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
// A real folder is deleted.
// A real folder is deleted, and kim's default session group for it goes with it.
const store = (server as unknown as { store: import('../src/state-store.js').StateStore }).store;
store.setCaseDefaultGroup('kim', 'proj1', null);
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
expect(del.status).toBe(200);
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
expect(store.getCaseDefaultGroup('kim', 'proj1')).toBeUndefined();
// Deleting it again 404s.
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
+253
View File
@@ -0,0 +1,253 @@
/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
__clearCodexAttributionMemo,
findExactCodexConversation,
resolveEffectiveCodexHome,
} from '../src/agent-codex-identity.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
const A = '11111111-1111-4111-8111-111111111111';
const B = '22222222-2222-4222-8222-222222222222';
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'agent-attribution-'));
roots.push(root);
const home = join(root, 'home');
await mkdir(join(home, 'sessions'), { recursive: true });
const options = { codexHome: home, sessionId: 'managed', workingDir: root };
const put = async (id: string, payload: object = {}, text?: string) => {
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
await writeFile(
path,
text ??
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: root, originator: 'codeman_managed', ...payload },
}) + '\n'
);
return path;
};
return { root, home, options, put };
}
it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => {
const f = await fixture();
const path = await f.put(A);
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
await f.put(A, { id: B });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('never chooses newest cwd files or an ambiguous managed originator', async () => {
const f = await fixture();
await f.put(A, { originator: 'codex_cli' });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
await f.put(A);
await f.put(B);
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('allows an explicit resume identity but still requires native metadata and the workspace', async () => {
const f = await fixture();
const path = await f.put(A, { originator: 'original-launch' });
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path });
await f.put(B);
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
await f.put(A, { cwd: f.home });
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
});
it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => {
const f = await fixture();
const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } });
await f.put(
A,
{},
kind === 'partial'
? row
: kind === 'duplicate'
? row + '\n' + row + '\n'
: row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n'
);
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('enforces traversal/read caps and ignores symlinked directories', async () => {
const f = await fixture();
await f.put(A);
expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined();
expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined();
await mkdir(join(f.root, 'elsewhere'));
await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias'));
expect((await findExactCodexConversation(f.options))?.id).toBe(A);
});
it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => {
expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom');
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process');
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex');
});
it.each([
['trailing-space', 'codex-home ', 'codex-home'],
['whitespace-only', ' ', '.codex'],
])(
'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper',
async (_label, configured, wrongHome) => {
const { Session } = await import('../src/session.js');
const f = await fixture();
const expectedHome = join(f.root, configured);
const siblingHome = join(f.root, wrongHome);
const record =
JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n';
for (const home of [expectedHome, siblingHome]) {
await mkdir(join(home, 'sessions'), { recursive: true });
await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record);
}
const session = new Session({
id: 'managed',
workingDir: f.root,
mode: 'codex',
envOverrides: { CODEX_HOME: configured },
});
try {
const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root });
expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({
id: A,
path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`),
});
expect(session.getCodexHomeOverride()).toBe(configured);
} finally {
await session.stop(false);
}
}
);
it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => {
const f = await fixture();
const path = await f.put(A);
await Promise.all(
Array.from({ length: 320 }, (_, n) => {
const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`;
return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) });
})
);
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
expect(
await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 })
).toBeUndefined();
});
it.each(['workspace', 'metadata'] as const)(
'probes %s bounded past the cap before canonical filesystem reads',
async (where) => {
const { vi } = await import('vitest');
const probe = await import('../src/utils/bounded-path-probe.js');
const f = await fixture();
const alias = join(f.root, 'workspace-alias');
await symlink(f.root, alias);
await f.put(A, { cwd: where === 'metadata' ? alias : f.root });
const blocked = where === 'workspace' ? f.root : alias;
const spy = vi
.spyOn(probe, 'probePathKind')
.mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory'));
try {
expect(await findExactCodexConversation(f.options)).toBeUndefined();
expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true });
} finally {
spy.mockRestore();
}
}
);
describe('remembered attribution on a large, never-pruned store', () => {
beforeEach(() => __clearCodexAttributionMemo());
const OWNED = '33333333-3333-4333-8333-333333333333';
const NEWER = '44444444-4444-4444-8444-444444444444';
/** codex's own layout: sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl */
async function dated(f: Awaited<ReturnType<typeof fixture>>, day: string, id: string, payload: object = {}) {
const dir = join(f.home, 'sessions', ...day.split('/'));
await mkdir(dir, { recursive: true });
const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`);
await writeFile(
path,
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload },
}) + '\n'
);
return path;
}
/** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */
async function unrelated(f: Awaited<ReturnType<typeof fixture>>, count: number) {
const instructions = 'x'.repeat(19 * 1024);
for (let start = 0; start < count; start += 200) {
await Promise.all(
Array.from({ length: Math.min(200, count - start) }, (_, k) => {
const n = start + k;
const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`;
return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, {
originator: 'codex_cli',
instructions,
});
})
);
}
}
const small = { maxEntries: 20, maxBytes: 1048576 };
it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => {
const f = await fixture();
await unrelated(f, 2100);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
}, 60_000);
it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => {
const f = await fixture();
await unrelated(f, 300);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
// 20 entries cannot cover 300 rollouts in 9 months of day directories: only the
// remembered head plus the directories not older than it fit.
expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path });
__clearCodexAttributionMemo();
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
});
it('still catches a newer owned rollout (codex /new) after remembering one', async () => {
const f = await fixture();
await unrelated(f, 50);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
await dated(f, '2026/10/25', NEWER);
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => {
const f = await fixture();
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
// 30 rollouts in the remembered day itself: the incremental scan reads them all,
// so 20 entries cannot cover it, and a full walk (a superset) could not either.
await Promise.all(
Array.from({ length: 30 }, (_, n) =>
dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' })
)
);
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
// The memo was dropped, so a call with room walks the store and attributes again.
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
});
it('drops the remembered file when it is replaced, and walks again', async () => {
const f = await fixture();
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
await rm(path);
await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
const moved = await dated(f, '2026/10/23', NEWER);
expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved });
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the
* experimental inspect/stage/submit routes: the strict settings schema admits it, App
* Settings loads and saves it under the same control id, and its row reads in Chinese
* (zh-CN) with no English left beyond the command and product names. The route gate itself
* is pinned in test/routes/agent-session-routes.test.ts.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
const ID = 'appSettingsAgentCoordination';
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */
const leftover = (text: string) =>
text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById(ID)!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const english = rowStrings(source.window.document);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const chinese = rowStrings(dom.window.document);
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('agentCoordinationEnabled', () => {
it('is a boolean the strict settings schema accepts, and nothing else', () => {
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false);
});
it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => {
expect(INDEX.split(`id="${ID}"`)).toHaveLength(2);
expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox');
expect(SETTINGS_UI).toContain(
`document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;`
);
expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`);
});
it('sits in the synced Cross-session features group', () => {
const group = source.window.document.getElementById(ID)!.closest('.set-group')!;
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features');
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced');
});
it('finds the row (the translation check is not vacuous)', () => {
expect(english.label).toBe('Experimental agent coordination');
expect(english.description).toMatch(/^Lets agents read another session's output/);
});
it('translates the label and the description, with no English left', () => {
for (const key of ['label', 'description'] as const) {
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
}
});
it('adds each key to the dictionary once', () => {
for (const text of [english.label, english.description]) {
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
}
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview Opt-in agent inbox snapshot (src/web/agent-inbox-persistence.ts).
*
* Off writes nothing, switching off deletes the file and keeps memory, on writes 0600
* through tmp + rename, and a boot with the setting on restores what was pending.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { AgentInbox } from '../src/web/agent-inbox.js';
import { AgentInboxPersistence } from '../src/web/agent-inbox-persistence.js';
describe('AgentInboxPersistence', () => {
let dir: string;
let file: string;
let inbox: AgentInbox;
let persistence: AgentInboxPersistence;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-inbox-'));
file = join(dir, 'agent-inbox.json');
inbox = new AgentInbox();
// Debounce 0 so a test can wait one macrotask instead of sleeping.
persistence = new AgentInboxPersistence(inbox, () => file, 0);
});
afterEach(() => {
persistence.dispose();
rmSync(dir, { recursive: true, force: true });
});
const tick = () => new Promise((r) => setTimeout(r, 5));
it('writes nothing while off (the default)', async () => {
persistence.load(false);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(false);
});
it('writes a 0600 snapshot when on, and no tmp file is left behind', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s[0].text).toBe('hello');
expect(statSync(file).mode & 0o777).toBe(0o600);
expect(existsSync(`${file}.tmp`)).toBe(false);
});
it('switching on writes the current inboxes at once', () => {
persistence.load(false);
inbox.post('s', null, 'posted while off');
persistence.setEnabled(true);
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
});
it('switching off deletes the snapshot and keeps the in-memory inboxes', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(true);
persistence.setEnabled(false);
expect(existsSync(file)).toBe(false);
expect(inbox.pendingCount('s')).toBe(1);
inbox.post('s', null, 'after off');
await tick();
expect(existsSync(file)).toBe(false);
});
it('a boot with the setting on restores pending mail', async () => {
persistence.load(true);
inbox.post('s', 'sender', 'survives');
await tick();
persistence.dispose();
const next = new AgentInbox();
const nextPersistence = new AgentInboxPersistence(next, () => file, 0);
expect(nextPersistence.load(true)).toBe(1);
expect(next.read('s', { receiver: false }).messages[0]).toMatchObject({ from: 'sender', text: 'survives' });
nextPersistence.dispose();
});
it('a boot with the setting off removes a file left from earlier and restores nothing', () => {
writeFileSync(file, JSON.stringify({ version: 1, inboxes: { s: [] } }));
expect(persistence.load(false)).toBe(0);
expect(existsSync(file)).toBe(false);
});
it('an unreadable file is an empty inbox, not a failed boot', () => {
writeFileSync(file, '{not json');
expect(persistence.load(true)).toBe(0);
});
it('flush writes a pending change before shutdown', () => {
const slow = new AgentInboxPersistence(inbox, () => file, 60_000);
slow.load(true);
inbox.post('s', null, 'just before shutdown');
expect(existsSync(file)).toBe(false);
slow.flush();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
slow.dispose();
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* @fileoverview How server.ts treats the agent inbox across a session's lifecycle.
*
* - Delete (`killMux=true`) drops the inbox; detach and shutdown (`killMux=false`)
* keep it, because a detached session comes back.
* - Boot pruning of orphaned inboxes runs only after a successful restore, and keeps
* every session state.json still holds plus every session on the reboot-restore
* offer (its record is already pruned, and a rebuild reuses the id). A failed
* restore leaves all mail alone.
*
* Uses a bare `WebServer.prototype` object with just the fields these paths touch,
* like tab-layout-restore-gate.test.ts.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { agentInbox } from '../src/web/agent-inbox.js';
import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
import type { RebootRestoreEntry } from '../src/reboot-restore.js';
import type { SessionState } from '../src/types.js';
type CleanupServer = {
_doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void>;
finalizeRestoredState(restored: boolean): Promise<void>;
[key: string]: unknown;
};
function bareServer(live: string[] = [], persisted: string[] = []): CleanupServer {
const server = Object.create(WebServer.prototype) as CleanupServer;
Object.assign(server, {
sessions: new Map(live.map((id) => [id, {}])),
keepWarmService: { remove: () => {} },
gitFetchScheduler: { remove: () => {} },
store: {
getSession: () => null,
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
removeRalphState: () => {},
},
tabLayouts: {
markRestorationComplete: () => {},
markRestorationFailed: () => {},
reconcileAfterRestoration: async () => {},
},
cleanupStaleSessions: async () => 0,
_customModelDisplacedNotified: new Set(),
respawnControllers: new Map(),
respawnTimers: new Map(),
pendingRespawnStarts: new Map(),
runSummaryTrackers: new Map(),
stopTranscriptWatcher: () => {},
persistDeb: { cancelKey: () => {} },
sse: { cleanupSessionBatches: () => {} },
// A real delete also tells the Discord bridge, which frees its per-tab state and may archive the thread.
discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} },
broadcast: () => {},
});
return server;
}
/** A reboot-restore offer entry; the prune reads only its `sessionId`. */
function offerEntry(sessionId: string): RebootRestoreEntry {
return {
sessionId,
workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}`,
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: { id: sessionId, workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}` } as SessionState,
};
}
describe('agent inbox server wiring', () => {
beforeEach(() => agentInbox.resetForTests());
afterEach(() => {
agentInbox.resetForTests();
rebootRestoreRegistry.reset();
});
it('a delete drops the inbox; a detach (killMux=false) keeps it', async () => {
const server = bareServer();
agentInbox.post('detached', null, 'still here after detach');
agentInbox.post('deleted', null, 'gone with the session');
await server._doCleanupSession('detached', false, 'test');
await server._doCleanupSession('deleted', true, 'test');
expect(agentInbox.pendingCount('detached')).toBe(1);
expect(agentInbox.pendingCount('deleted')).toBe(0);
});
it('a failed restore prunes nothing', async () => {
const server = bareServer();
agentInbox.post('not-restored', null, 'm');
await server.finalizeRestoredState(false);
expect(agentInbox.pendingCount('not-restored')).toBe(1);
});
it('a successful restore prunes orphans and keeps live and persisted sessions', async () => {
const server = bareServer(['live'], ['kept-for-recovery']);
for (const id of ['live', 'kept-for-recovery', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(agentInbox.pendingCount('live')).toBe(1);
expect(agentInbox.pendingCount('kept-for-recovery')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
it('a successful restore keeps the inbox of a session on the reboot-restore offer', async () => {
// After a host reboot the dead session is in neither `this.sessions` nor state.json
// (cleanupStaleSessions already pruned its record), only in the offer, and the
// rebuild reuses its id, so its mail must still be there when it comes back.
const server = bareServer();
rebootRestoreRegistry.set([offerEntry('on-offer')]);
for (const id of ['on-offer', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['on-offer']);
expect(agentInbox.pendingCount('on-offer')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
});
+279
View File
@@ -0,0 +1,279 @@
/**
* @fileoverview The agent inbox store (src/web/agent-inbox.ts), pure: no routes, no IO.
*
* Each block pins one rule from discussion #558: read until ack, seen only for the
* receiver, byte and count caps that refuse instead of dropping, a TTL counted from
* each message's own timestamp, and a snapshot that survives malformed input.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
AgentInbox,
MAX_INBOX_BYTES,
MAX_MESSAGE_BYTES,
MAX_MESSAGES_PER_INBOX,
MESSAGE_TTL_MS,
} from '../src/web/agent-inbox.js';
const RX = 'receiver-session';
describe('AgentInbox', () => {
let now: number;
let inbox: AgentInbox;
beforeEach(() => {
now = 1_000_000;
inbox = new AgentInbox(() => now);
});
const post = (text: string, from: string | null = 'sender') => {
const r = inbox.post(RX, from, text);
if (!r.ok) throw new Error(`post refused: ${r.reason}`);
return r.message;
};
describe('read until ack', () => {
it('a read removes nothing; the same messages come back until acked', () => {
const a = post('first');
const b = post('second');
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.pendingCount(RX)).toBe(2);
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([b.id]);
});
it('ack is idempotent: an id that is already gone or never existed is not an error', () => {
const a = post('x');
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.ack(RX, [a.id])).toBe(0);
expect(inbox.ack(RX, ['no-such-id'])).toBe(0);
expect(inbox.ack('no-such-session', [a.id])).toBe(0);
});
it('ack removes only the given ids, never a message posted after the read', () => {
const a = post('read me');
inbox.read(RX, { receiver: true });
const late = post('arrived after the read');
inbox.ack(RX, [a.id]);
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
});
it('keeps multi-line text byte for byte', () => {
const text = 'line one\nline two\r\n\ttabbed\n';
post(text);
expect(inbox.read(RX, { receiver: true }).messages[0].text).toBe(text);
});
});
describe('seen belongs to the receiver', () => {
it('a receiver read marks seen; the flag on each message is the state BEFORE this read', () => {
post('m');
const first = inbox.read(RX, { receiver: true });
expect(first.messages[0].seen).toBe(false);
expect(first.unseen).toBe(0);
const second = inbox.read(RX, { receiver: true });
expect(second.messages[0].seen).toBe(true);
});
it('a peek changes nothing: unseen stays unseen', () => {
post('m');
const peek = inbox.read(RX, { receiver: false });
expect(peek.messages[0].seen).toBe(false);
expect(peek.unseen).toBe(1);
expect(inbox.unseenCount(RX)).toBe(1);
expect(inbox.summary().get(RX)).toMatchObject({ pending: 1, unseen: 1 });
});
it('a message posted after a receiver read is unseen again', () => {
post('a');
inbox.read(RX, { receiver: true });
expect(inbox.unseenCount(RX)).toBe(0);
post('b');
expect(inbox.unseenCount(RX)).toBe(1);
});
});
describe('caps refuse, never drop the oldest', () => {
it(`refuses message ${MAX_MESSAGES_PER_INBOX + 1} and keeps the first one`, () => {
const first = post('0');
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) post(String(i));
expect(inbox.post(RX, null, 'one too many')).toEqual({ ok: false, reason: 'inbox-full-count' });
const ids = inbox.read(RX, { receiver: false }).messages.map((m) => m.id);
expect(ids).toHaveLength(MAX_MESSAGES_PER_INBOX);
expect(ids[0]).toBe(first.id);
});
it('counts the per-message cap in UTF-8 bytes, not characters', () => {
// 'é' is 2 bytes: half the byte budget in characters already fills it.
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2)).ok).toBe(true);
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2 + 1))).toEqual({
ok: false,
reason: 'message-too-large',
});
});
it('refuses a post that would cross the per-inbox byte cap', () => {
const chunk = 'x'.repeat(MAX_MESSAGE_BYTES);
const fit = Math.floor(MAX_INBOX_BYTES / MAX_MESSAGE_BYTES);
for (let i = 0; i < fit; i++) post(chunk);
expect(inbox.post(RX, null, 'x'.repeat(MAX_INBOX_BYTES - fit * MAX_MESSAGE_BYTES + 1))).toEqual({
ok: false,
reason: 'inbox-full-bytes',
});
expect(inbox.pendingCount(RX)).toBe(fit);
});
it('an ack makes room again', () => {
const ids: string[] = [];
for (let i = 0; i < MAX_MESSAGES_PER_INBOX; i++) ids.push(post(String(i)).id);
inbox.ack(RX, [ids[0]]);
expect(inbox.post(RX, null, 'now it fits').ok).toBe(true);
});
it('refuses empty text', () => {
expect(inbox.post(RX, null, '')).toEqual({ ok: false, reason: 'empty' });
});
});
describe('TTL from the message timestamp', () => {
it('a message older than the TTL is gone, read or not', () => {
post('old');
now += MESSAGE_TTL_MS / 2;
const fresh = post('fresh');
now += MESSAGE_TTL_MS / 2 + 1;
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([fresh.id]);
});
it('a restore keeps createdAt, so a restart does not restart the clock', () => {
post('m');
const snap = JSON.parse(JSON.stringify(inbox.snapshot()));
now += MESSAGE_TTL_MS - 1000;
const restarted = new AgentInbox(() => now);
expect(restarted.restore(snap)).toBe(1);
now += 1001;
expect(restarted.pendingCount(RX)).toBe(0);
});
it('does not restore a message that expired while the server was down', () => {
post('m');
const snap = inbox.snapshot();
now += MESSAGE_TTL_MS + 1;
expect(new AgentInbox(() => now).restore(snap)).toBe(0);
});
it('sweeps an expired message that is not first (clock stepped back)', () => {
const late = post('posted first, stamped later');
now -= 1000; // the clock steps back
const early = post('posted second, stamped earlier');
now += MESSAGE_TTL_MS + 1000 - (late.createdAt - early.createdAt) / 2;
// `early` is past the TTL, `late` is not, and `early` sits behind `late`.
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
});
it('expiry fires onChange so the snapshot catches up', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
now += MESSAGE_TTL_MS + 1;
inbox.summary();
expect(changes).toBe(1);
});
});
describe('summary', () => {
it('reports counts and the oldest unseen time, never text', () => {
const a = post('secret one');
now += 10;
post('secret two');
inbox.read(RX, { receiver: true });
now += 10;
const c = post('secret three');
const row = inbox.summary().get(RX);
expect(row).toEqual({ pending: 3, unseen: 1, oldestUnseenAt: c.createdAt });
expect(JSON.stringify(row)).not.toContain('secret');
expect(a.createdAt).toBeLessThan(c.createdAt);
});
it('leaves out sessions with an empty inbox', () => {
const a = post('m');
inbox.ack(RX, [a.id]);
expect(inbox.summary().has(RX)).toBe(false);
});
});
describe('lifecycle', () => {
it('drop discards the inbox and fires onChange', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
inbox.drop(RX);
expect(inbox.pendingCount(RX)).toBe(0);
expect(changes).toBe(1);
});
it('pruneExcept drops only inboxes of sessions not kept', () => {
inbox.post('keep-me', null, 'a');
inbox.post('gone', null, 'b');
expect(inbox.pruneExcept(['keep-me'])).toBe(1);
expect(inbox.pendingCount('keep-me')).toBe(1);
expect(inbox.pendingCount('gone')).toBe(0);
});
});
describe('snapshot / restore', () => {
it('round-trips messages, senders and the seen state', () => {
const a = post('from a session', 'sender-1');
post('from the user', null);
inbox.read(RX, { receiver: true });
const c = post('unseen');
const restored = new AgentInbox(() => now);
expect(restored.restore(JSON.parse(JSON.stringify(inbox.snapshot())))).toBe(3);
const read = restored.read(RX, { receiver: false });
expect(read.messages.map((m) => [m.id, m.from, m.seen])).toEqual([
[a.id, 'sender-1', true],
[expect.any(String), null, true],
[c.id, 'sender', false],
]);
});
it('skips malformed entries and files instead of throwing', () => {
const restored = new AgentInbox(() => now);
expect(restored.restore(null)).toBe(0);
expect(restored.restore({ version: 2, inboxes: {} })).toBe(0);
expect(restored.restore({ version: 1, inboxes: { s: 'nope' } })).toBe(0);
const good = { id: 'g', from: null, text: 'ok', createdAt: now, seenAt: null };
expect(
restored.restore({
version: 1,
inboxes: {
s: [
good,
{ id: 1 },
{ ...good, id: 'big', text: 'x'.repeat(MAX_MESSAGE_BYTES + 1) },
{ ...good, text: '' },
],
},
})
).toBe(1);
});
it('restores oldest first whatever the file order, so the caps keep the oldest', () => {
const msg = (id: string, createdAt: number) => ({ id, from: null, text: 'x', createdAt, seenAt: null });
const restored = new AgentInbox(() => now);
restored.restore({ version: 1, inboxes: { s: [msg('new', now - 10), msg('old', now - 20)] } });
expect(restored.read('s', { receiver: false }).messages.map((m) => m.id)).toEqual(['old', 'new']);
});
it('holds the count cap on restore', () => {
const many = Array.from({ length: MAX_MESSAGES_PER_INBOX + 5 }, (_, i) => ({
id: `m${i}`,
from: null,
text: 'x',
createdAt: now,
seenAt: null,
}));
const restored = new AgentInbox(() => now);
expect(restored.restore({ version: 1, inboxes: { s: many } })).toBe(MAX_MESSAGES_PER_INBOX);
});
});
});
+202
View File
@@ -0,0 +1,202 @@
/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */
import { afterEach, beforeEach, expect, it } from 'vitest';
import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js';
let dir: string;
let file: string;
let reader: InspectionReader;
const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' };
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'inspect-'));
file = join(dir, 'rollout.jsonl');
reader = new InspectionReader();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('returns complete UTF8 records within the byte budget and resumes exactly', async () => {
const row = JSON.stringify({ text: 'é😀' }) + '\n';
await writeFile(file, row + row);
const first = await reader.read(file, scope, Buffer.byteLength(row));
expect(first.evidence).toBe(row);
expect(first.truncated).toBe(true);
await appendFile(file, row);
const next = await reader.read(file, scope, 1024, first.cursor);
expect(next.evidence).toBe(row);
expect(next.truncated).toBe(false);
});
it('does not consume partial final records', async () => {
await writeFile(file, '{"text":"hello');
const first = await reader.read(file, scope, 100);
expect(first.evidence).toBe('');
await appendFile(file, '"}\n');
const next = await reader.read(file, scope, 100, first.cursor);
expect(next.evidence).toBe('{"text":"hello"}\n');
});
it('reports an oversized incremental record without splitting it or advancing past it', async () => {
await writeFile(file, '{}\n');
const initial = await reader.read(file, scope, 100);
await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n');
const result = await reader.read(file, scope, 100, initial.cursor);
expect(result.evidence).toBe('');
expect(result.truncated).toBe(true);
expect(result.blockedReason).toBe('record-exceeds-scan-limit');
expect(result.scannedBytes).toBeLessThanOrEqual(262144);
});
it('rejects malformed, cross-owner and changed-session cursors', async () => {
await writeFile(file, '{}\n');
const first = await reader.read(file, scope, 100);
for (const changed of [
{ ...scope, owner: 'bob' },
{ ...scope, sessionId: 'other' },
{ ...scope, generation: 'new' },
{ ...scope, conversationId: 'new' },
]) {
await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i);
}
await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i);
});
it('rejects truncation, replacement and rewritten consumed evidence', async () => {
await writeFile(file, '{}\n{}\n');
const first = await reader.read(file, scope, 100);
await writeFile(file, '{}\n');
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
await writeFile(file, '{}\n{}\n');
const second = await reader.read(file, scope, 100);
await writeFile(file, '[]\n[]\n');
await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i);
const third = await reader.read(file, scope, 100);
await rename(file, join(dir, 'old'));
await writeFile(file, '[]\n[]\n');
await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i);
});
it('expires cursors and bounds retained state', async () => {
await writeFile(file, '{}\n');
let now = 0;
reader = new InspectionReader(() => now, 2, 1000);
const first = await reader.read(file, scope, 100);
await reader.read(file, scope, 100);
await reader.read(file, scope, 100);
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
const last = await reader.read(file, scope, 100);
now = 1001;
await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i);
});
it('bounds strict attribution itself and refuses an incomplete inventory', async () => {
const { mkdir } = await import('node:fs/promises');
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
const home = join(dir, 'codex');
const sessions = join(home, 'sessions');
await mkdir(sessions, { recursive: true });
const id = '11111111-1111-4111-8111-111111111111';
await writeFile(
join(sessions, `rollout-${id}.jsonl`),
JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n'
);
await writeFile(join(sessions, 'other.txt'), 'other');
const options = { codexHome: home, sessionId: 's', workingDir: dir };
expect((await findExactCodexConversation(options))?.id).toBe(id);
expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined();
});
it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => {
const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n';
const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n';
await writeFile(file, old + latest);
const result = await reader.read(file, scope, Buffer.byteLength(latest));
expect(result.evidence).toBe(latest);
expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old));
expect(result.truncated).toBe(true);
await appendFile(file, '{"assistant":"next"}\n');
expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n');
});
it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => {
const { mkdir } = await import('node:fs/promises');
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
const home = join(dir, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const id = '11111111-1111-4111-8111-111111111111';
await writeFile(
join(home, 'sessions', `rollout-${id}.jsonl`),
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) },
}) + '\n'
);
expect(
(
await findExactCodexConversation(
{ codexHome: home, sessionId: 's', workingDir: dir },
{ maxEntries: 8192, maxBytes: 1048576 }
)
)?.id
).toBe(id);
});
it('rejects same-size rewrites outside the bounded cursor anchors', async () => {
const { utimes } = await import('node:fs/promises');
const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n';
await writeFile(file, row);
await utimes(file, 1, 1);
const first = await reader.read(file, scope, 30000);
await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001));
await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i);
});
it('keeps cursor capacity bounded across concurrent reads', async () => {
await writeFile(file, '{}\n');
reader = new InspectionReader(Date.now, 2);
const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100)));
const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor)));
expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2);
});
it('caps cursors per owner and session, so one polling scope cannot evict another', async () => {
await writeFile(file, '{}\n');
const other = { ...scope, owner: 'bob', sessionId: 't' };
const bobs = await reader.read(file, other, 100);
// Alice polls every second for well past the old global FIFO of 1,024.
let last = await reader.read(file, scope, 100);
for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor);
// Bob's cursor survives, and so does a retry of Alice's last call.
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' });
await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' });
// Alice keeps only her newest cursors: the oldest of one more than the cap is retired.
const issued: string[] = [];
for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor);
await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i);
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined();
// The cap counts owner and session: a new pane generation of Alice's session shares
// it, Bob's session does not.
const nextPane = { ...scope, generation: 'pane-2' };
for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100);
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i);
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined();
});
it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => {
const row = Buffer.from('{"text":"😀"}\n');
const split = row.indexOf(Buffer.from('😀')) + 2;
await writeFile(file, row.subarray(0, split));
const first = await reader.read(file, scope, 100);
expect(first.evidence).toBe('');
await appendFile(file, row.subarray(split));
expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8'));
});
it('treats a full incremental page as pageable rather than a blocked oversized record', async () => {
await writeFile(file, '{}\n');
const initial = await reader.read(file, scope, 100);
await appendFile(file, '{}\n{}\n');
const page = await reader.read(file, scope, 3, initial.cursor);
expect(page.evidence).toBe('{}\n');
expect(page.truncated).toBe(true);
expect(page.blockedReason).toBeUndefined();
const next = await reader.read(file, scope, 3, page.cursor);
expect(next.evidence).toBe('{}\n');
});
it('still labels the first selected oversized tail record after omitted older bytes', async () => {
await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n');
const page = await reader.read(file, scope, 10);
expect(page.evidence).toBe('');
expect(page.blockedReason).toBe('record-exceeds-output-budget');
});
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview `sendwait` in the packaged agent preamble sends one line.
*
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub
* that records the request body, so it checks what the function puts on the wire,
* not a copy of its logic. The server pastes a multi-line prompt as one block, and
* neither sendwait's Enter loop nor the server's submit check can recognise a pasted
* block still sitting on the composer, so the prompt must arrive as one line, the
* way `toSingleLine` joins it for the other unattended senders.
*
* Port: N/A (no server; the stub answers every request).
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { toSingleLine } from '../src/utils/single-line.js';
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
// The preamble builds every request body with jq.
let hasJq = true;
try {
execFileSync('jq', ['--version'], { stdio: 'ignore' });
} catch {
hasJq = false;
}
/** The `input` field of the first body sendwait posts for `prompt`. */
function sentInput(prompt: string): string {
// sendwait calls CURL inside $(...), a subshell, so the stub records to a file.
const dir = mkdtempSync(join(tmpdir(), 'codeman-sendwait-'));
const bodies = join(dir, 'bodies');
const script = `
. "$PREAMBLE" >/dev/null
stub() {
while [ "$#" -gt 0 ]; do
if [ "$1" = --data-binary ]; then printf '%s\\n' "$2" >> "$BODIES"; shift; fi
shift
done
printf '%s' '{"data":{"delivered":true,"wait":{"timedOut":false}}}'
}
CURL=(stub)
sendwait worker1234 "$PROMPT" 7 >/dev/null
`;
try {
execFileSync('bash', ['-c', script], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
PREAMBLE,
PROMPT: prompt,
BODIES: bodies,
CODEMAN_API_URL: 'http://127.0.0.1:9',
CODEMAN_SESSION_ID: 'caller-session-0000',
},
});
const first = readFileSync(bodies, 'utf-8').split('\n')[0];
return (JSON.parse(first) as { input: string }).input;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe.skipIf(!hasJq)('preamble sendwait', () => {
it('joins a multi-line prompt onto one line before sending it', () => {
expect(sentInput('Review this:\n- item 1\n- item 2')).toBe('Review this: - item 1 - item 2\r');
});
it('joins the same way as toSingleLine (trimmed lines, blank lines dropped, \\r\\n as a break)', () => {
const prompt = ' first line \r\n\n\t second\n\n \nthird\n';
expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r');
});
it('refuses a prompt made only of blank lines instead of sending a bare Enter', () => {
expect(() => sentInput(' \n \t\n')).toThrow(/sendwait: empty prompt/);
});
it('leaves a single-line prompt, glob characters included, as it was', () => {
expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r');
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* @fileoverview Agent skill per CLI: which workspace directory each CLI's sessions get
* the one packaged skill in (`capabilities.agentSkillDir`), and the
* `codeman skill install|uninstall` resolution that follows the registry.
*
* The skill is read from the REAL packaged source (`skills/codeman/`), so this also
* guards that it meets the Agent Skills format every CLI (and the `npx skills`
* installer) parses: a `name` equal to its directory, lowercase with hyphens, and a
* description of at most 1024 characters.
*
* Pure filesystem and registry tests in a per-test temp dir. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, mkdir, readFile, readdir } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { agentSkillDir, agentSkillDirFor, applyAgentSkill, CLAUDE_AGENT_SKILL_DIR } from '../src/hooks-config.js';
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { registryAgentSkillDirs, resolveSkillInstallDir } from '../src/cli.js';
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
const AGENTS_DIR = '.agents/skills' as const;
/** The frontmatter fields the Agent Skills format defines, with a `>-` folded description joined. */
function frontmatter(skillMd: string): { name?: string; description?: string } {
const match = /^---\n([\s\S]*?)\n---\n/.exec(skillMd);
if (!match) return {};
const lines = match[1].split('\n');
const out: { name?: string; description?: string } = {};
for (let i = 0; i < lines.length; i++) {
const name = /^name:\s*(.+)$/.exec(lines[i]);
if (name) out.name = name[1].trim();
const desc = /^description:\s*(.*)$/.exec(lines[i]);
if (desc) {
if (desc[1].trim() === '>-' || desc[1].trim() === '>') {
const folded: string[] = [];
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) folded.push(lines[++i].trim());
out.description = folded.join(' ');
} else {
out.description = desc[1].trim();
}
}
}
return out;
}
let casePath: string;
beforeEach(async () => {
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-dirs-'));
});
afterEach(async () => {
await rm(casePath, { recursive: true, force: true });
});
describe('the packaged skill meets the Agent Skills format', () => {
it('skills/codeman/SKILL.md: name equals the directory, description within limits', () => {
const fm = frontmatter(readFileSync(join(process.cwd(), 'skills', 'codeman', 'SKILL.md'), 'utf-8'));
expect(fm.name).toBe('codeman');
expect(fm.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
expect(fm.description, 'description').toBeTruthy();
expect(fm.description!.length).toBeLessThanOrEqual(1024);
// It refuses outside Codeman, and says so where every CLI reads first.
expect(fm.description).toContain('CODEMAN_MUX=1');
});
it('is the only packaged skill (one skill for every CLI)', async () => {
expect(await readdir(join(process.cwd(), 'skills'))).toEqual(['codeman']);
});
});
describe('applyAgentSkill with a directory', () => {
it('installs into <case>/.agents/skills/codeman, marker appended, and nothing under .claude', async () => {
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('installed');
const skillDir = join(casePath, '.agents', 'skills', 'codeman');
const skillMd = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(join(casePath, '.claude'))).toBe(false);
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
});
it('removes it again and prunes the emptied skills dir, never the CLI dir above it', async () => {
await applyAgentSkill(casePath, true, AGENTS_DIR);
expect(await applyAgentSkill(casePath, false, AGENTS_DIR)).toBe('removed');
expect(existsSync(join(casePath, '.agents', 'skills'))).toBe(false);
expect(existsSync(join(casePath, '.agents'))).toBe(true);
});
it('two CLIs sharing one workspace each get an identical copy where they look', async () => {
await applyAgentSkill(casePath, true, CLAUDE_AGENT_SKILL_DIR);
await applyAgentSkill(casePath, true, AGENTS_DIR);
const claudeCopy = await readFile(join(casePath, '.claude', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
const agentsCopy = await readFile(join(casePath, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
expect(agentsCopy).toBe(claudeCopy);
});
it("the default directory is still Claude Code's", async () => {
expect(await applyAgentSkill(casePath, true)).toBe('installed');
expect(existsSync(agentSkillDirFor(casePath, CLAUDE_AGENT_SKILL_DIR))).toBe(true);
});
it('writes nothing outside the target directory', async () => {
await mkdir(join(casePath, 'keep'), { recursive: true });
await applyAgentSkill(casePath, true, AGENTS_DIR);
expect((await readdir(casePath)).sort()).toEqual(['.agents', 'keep']);
});
});
describe('agentSkillDir', () => {
it('is null when the CLI takes no skill', () => {
expect(agentSkillDir({ agentSkillInjection: false, agentSkillDir: AGENTS_DIR })).toBeNull();
expect(agentSkillDir(undefined)).toBeNull();
});
it("falls back to Claude Code's directory when only the old boolean is set (pre-field clis.json)", () => {
expect(agentSkillDir({ agentSkillInjection: true })).toBe(CLAUDE_AGENT_SKILL_DIR);
});
it('uses the declared directory otherwise', () => {
expect(agentSkillDir({ agentSkillInjection: true, agentSkillDir: AGENTS_DIR })).toBe(AGENTS_DIR);
});
});
describe('the stock registry', () => {
it('pins the per-CLI table (measured 2026-10-11, see AGENTS_DIR_SKILL in stock.ts)', () => {
const table = Object.fromEntries(
['claude', 'codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'shell'].map((id) => [
id,
agentSkillDir(getCli(id)?.capabilities),
])
);
expect(table).toEqual({
claude: '.claude/skills',
codex: AGENTS_DIR,
gemini: AGENTS_DIR,
opencode: AGENTS_DIR,
antigravity: AGENTS_DIR,
// A project-level skill makes pi ask "Trust project folder?" at startup.
pi: null,
grok: AGENTS_DIR,
deepseek: AGENTS_DIR,
omp: AGENTS_DIR,
shell: null,
});
});
});
describe('capabilities.agentSkillDir validation (clis.json is user-editable)', () => {
const withDir = (dir: unknown) => {
const claude = getCli('claude')!;
return CliEntrySchema.safeParse({ ...claude, capabilities: { ...claude.capabilities, agentSkillDir: dir } });
};
it('accepts a listed directory', () => {
expect(withDir('.agents/skills').success).toBe(true);
});
it('refuses a free path, traversal included', () => {
expect(withDir('../../.ssh').success).toBe(false);
expect(withDir('/etc').success).toBe(false);
expect(withDir('.pi/skills').success).toBe(false);
});
});
describe('codeman skill install|uninstall follow the registry', () => {
it('--mode claude (the default) installs into .claude/skills, user-level or per case', () => {
expect(resolveSkillInstallDir({})).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
expect(resolveSkillInstallDir({ mode: 'claude', case: 'x' })).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
});
it('--mode codex installs per case into .agents/skills, and points a user-level install at npx skills', () => {
expect(resolveSkillInstallDir({ mode: 'codex', case: 'x' })).toEqual({ skillDir: AGENTS_DIR });
expect(resolveSkillInstallDir({ mode: 'codex' }).error).toContain('npx skills add Ark0N/Codeman --skill codeman -g');
});
it('refuses a mode without a skill and an unknown mode', () => {
expect(resolveSkillInstallDir({ mode: 'shell', case: 'x' }).error).toContain('takes no Codeman agent skill');
expect(resolveSkillInstallDir({ mode: 'nope', case: 'x' }).error).toContain('Unknown mode');
});
it('uninstall --case covers every distinct directory the registry can write, once each', () => {
const dirs = registryAgentSkillDirs();
expect(new Set(dirs).size).toBe(dirs.length);
expect(dirs).toContain(CLAUDE_AGENT_SKILL_DIR);
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) expect(dirs, cli.id).toContain(dir);
}
});
});
+24
View File
@@ -163,6 +163,30 @@ describe('preamble single-source (seed + §0 heredoc parity)', () => {
expect(preamble).toBe(heredoc);
});
it("SKILL.md's shell survives Claude Code's skill-argument substitution unchanged", async () => {
// When Claude Code loads a skill WITH arguments (the Skill tool passes the user's request words),
// it rewrites the skill body before the model sees it. Measured on 2.1.300: every `$ARGUMENTS[N]`,
// `$ARGUMENTS` and bare `$N` (digits, then no word character) becomes the matching argument, 0-based,
// and a backslash before `$` is consumed as an escape. A bare `$1` in §0 arrived as
// `sessions/5/terminal` and `timeout=claude` for "start 5 claude workers", so an agent pasting §0
// wrote a broken preamble. `${1}` is the same bash and is never touched; keep it that way.
const skillMd = await readFile(join(packagedDir, 'SKILL.md'), 'utf-8');
const substitute = (body: string, args: string[]): string =>
body
.replace(/(?<!\\)\\\$(?=\d|ARGUMENTS)/g, '$')
.replace(/\$ARGUMENTS\[(\d+)\]/g, (m, i: string) => args[Number(i)] ?? m)
.replace(/\$(\d+)(?!\w)/g, (m, i: string) => args[Number(i)] ?? m)
.replaceAll('$ARGUMENTS', args.join(' '));
const args = 'start 5 claude workers, one per TODO item'.split(/\s+/);
expect(substitute(skillMd, args)).toBe(skillMd);
// And the heredoc an agent pastes from the substituted body is still the seeded preamble.
const rendered = substitute(skillMd, args);
const start = rendered.indexOf("<<'PREAMBLE'\n") + "<<'PREAMBLE'\n".length;
const preamble = await readFile(join(packagedDir, 'preamble.sh'), 'utf-8');
expect(rendered.slice(start, rendered.indexOf('\nPREAMBLE\n', start) + 1)).toBe(preamble);
});
it('seedAgentSessionPreamble writes the stamped preamble to the XDG cache path, 0600', async () => {
const prevXdg = process.env.XDG_CACHE_HOME;
const cacheDir = join(casePath, 'xdg-cache');
+544
View File
@@ -0,0 +1,544 @@
/** @fileoverview Guarded input observes all production write and lifecycle primitives. */
import { expect, it, vi } from 'vitest';
import { formatPaneSnapshot } from '../src/tmux-manager.js';
import { Session } from '../src/session.js';
import { promptSha256 } from '../src/agent-staging.js';
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
let screen = '› \n GPT-6-Luna default · ~/case\n';
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(screen.trimEnd().split('\n'), {
cols: 100,
rows: 20,
cursorX: screen.startsWith('› hello') ? 7 : 2,
cursorY: 0,
});
},
sendInput: async () => true,
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const before = session.guardedInputSnapshot();
expect(before?.composerSha).toBe(promptSha256(''));
await session.writeViaMux('hello');
screen = '› hello\n GPT-6-Luna default · ~/case\n';
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1);
session.trackUserInput('x');
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2);
await session.performAuxiliaryInput(async () => {});
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3);
const generation = session.inspectionGeneration;
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {
expect(session.guardedInputSnapshot()).toBeNull();
}
);
expect(session.inspectionGeneration).not.toBe(generation);
session.markPromptSubmitted();
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4);
await session.stop(false);
expect(session.guardedInputSnapshot()).toBeNull();
});
it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => {
let dispatch: (() => void) | undefined;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
await new Promise<void>((resolve) => {
dispatch = resolve;
});
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
const expected = session.guardedInputSnapshot()!;
const sending = session.submitGuardedInput(expected);
// Held behind the lease, not refused: it still invalidates the pending Enter, and
// it goes out once the lease is released.
const other = session.writeViaMux('other');
dispatch!();
expect(await sending).toBe(false);
expect(writes).toEqual([]);
await settle();
dispatch!();
expect(await other).toBe(true);
expect(writes).toEqual(['other']);
const next = session.submitGuardedInput(session.guardedInputSnapshot()!);
dispatch!();
expect(await next).toBe(true);
expect(writes).toEqual(['other', '\r']);
const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!);
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {}
);
dispatch!();
expect(await restarting).toBe(false);
expect(writes).toEqual(['other', '\r']);
const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!);
const key = session.performAuxiliaryInput(async () => {
writes.push('aux');
});
await settle();
// The named key waits for the lease instead of failing, and never runs inside it.
expect(writes).toEqual(['other', '\r']);
dispatch!();
expect(await auxiliary).toBe(false);
await key;
expect(writes).toEqual(['other', '\r', 'aux']);
} finally {
await session.stop(false);
}
});
it('cancels an older automatic submit verifier before staging an identical prompt', async () => {
vi.useFakeTimers();
let composer = '';
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 2 + composer.length,
cursorY: 0,
});
},
capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`,
sendInput: async (_id: string, text: string) => {
writes.push(text);
composer += text;
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
session.verifySubmitted('hello');
expect(await session.stageGuardedInput('hello')).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(writes).toEqual(['hello']);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('routes automatic verifier Enter through the shared guarded input boundary', async () => {
vi.useFakeTimers();
let release!: () => void;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case',
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch) {
await new Promise<void>((resolve) => {
release = resolve;
});
if (!options.beforeDispatch()) return false;
}
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
session.verifySubmitted('hello');
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
await vi.advanceTimersByTimeAsync(2000);
release();
expect(await submission).toBe(false);
await vi.advanceTimersByTimeAsync(60000);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => {
let release!: () => void;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
await new Promise<void>((resolve) => {
release = resolve;
});
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
try {
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
// This also prevents a real spawn if the lease guard regresses.
access.ptyProcess = {
write: () => {
throw new Error('must not write');
},
};
await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission');
await expect(session.sendInput('other')).rejects.toThrow('Guarded submission');
access.ptyProcess = null;
release();
expect(await submission).toBe(false);
expect(writes).toEqual([]);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('counts an ordinary transport pending window before any awaited acknowledgement', async () => {
let release!: () => void;
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
sendInput: async () => {
await new Promise<void>((resolve) => {
release = resolve;
});
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const writing = session.writeViaMux('hello');
expect(session.guardedInputPendingWrites).toBe(1);
release();
await writing;
expect(session.guardedInputPendingWrites).toBe(0);
} finally {
release?.();
await session.stop(false);
}
});
it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => {
let working = false;
const capture = vi.fn(() =>
formatPaneSnapshot(
[working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'],
{ cols: 100, rows: 20, cursorX: 7, cursorY: 1 }
)
);
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return capture();
},
capturePaneText: () => {
throw Error('must use same frame');
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked');
expect(session.guardedInputSnapshot()).not.toBeNull();
expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n');
working = true;
expect(session.guardedInputSnapshot()).toBeNull();
blocked.mockReturnValue(true);
working = false;
expect(session.guardedInputSnapshot()).toBeNull();
} finally {
await session.stop(false);
}
});
it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => {
const writes: string[] = [];
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as {
ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null;
};
access.ptyProcess = {
write: (text) => {
writes.push(text);
},
resize: () => {},
};
try {
const before = session.guardedInputSnapshot()!;
for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) {
expect(session.write(data, { fromUser: true })).toBe(true);
expect(writes.at(-1)).toBe(data);
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision);
}
session.write('\x1b[Oedit');
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1);
const generation = session.inspectionGeneration;
session.owner = 'other';
session.owner = undefined;
expect(session.inspectionGeneration).not.toBe(generation);
const priorResize = session.guardedInputGeneration;
session.resize(80, 24);
expect(session.guardedInputGeneration).not.toBe(priorResize);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('refuses a message that would wrap using the actual captured indent and width before any write', async () => {
const writes: string[] = [];
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane: string,
opts: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 30, rows: 20 };
return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], {
cols: 30,
rows: 20,
cursorX: 6,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string) => {
writes.push(text);
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
const { getCli } = await import('../src/config/cli-registry/registry.js');
try {
const store = new GuardedStageStore();
const target = {
id: session.id,
chrome: () => guardedComposerChrome(getCli('codex')),
snapshot: () => session.guardedInputSnapshot(),
write: (text: string) => session.stageGuardedInput(text),
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
};
await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
}
});
it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
access.ptyProcess = { resize: () => {} };
try {
const inspection = session.inspectionGeneration;
const staging = session.guardedInputGeneration;
session.resize(80, 24);
expect(session.inspectionGeneration).toBe(inspection);
expect(session.guardedInputGeneration).not.toBe(staging);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('records a verifier re-press as the latest submit time', async () => {
vi.useFakeTimers();
vi.setSystemTime(10000);
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true },
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
await session.writeViaMux('hello\r');
const initial = session.lastSubmitAt;
await vi.advanceTimersByTimeAsync(2000);
expect(session.lastSubmitAt).toBe(initial + 2000);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => {
// Reproduces the live 0.162.0 race: right after the keys land, the screen still
// shows the empty composer while the caret is already past the new text. That
// frame proves nothing; the repaint a few ms later does.
let composer = '';
let torn = 0;
const writes: string[] = [];
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
const shown = torn > 0 ? '' : composer;
if (torn > 0) torn--;
return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 2 + composer.length,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
if (text !== '\r') {
composer += text;
torn = 2;
}
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
const { getCli } = await import('../src/config/cli-registry/registry.js');
const sleeps: number[] = [];
const store = new GuardedStageStore(Date.now, 1024, 120000, {
sleep: async (ms) => {
sleeps.push(ms);
},
});
const target = {
id: session.id,
chrome: () => guardedComposerChrome(getCli('codex')),
snapshot: () => session.guardedInputSnapshot(),
write: (text: string) => session.stageGuardedInput(text),
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
};
try {
const receipt = await store.stage(target, 'alice', 'hello');
expect(receipt.sha256).toBe(promptSha256('hello'));
expect(sleeps).toHaveLength(2);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({
submitted: true,
});
expect(writes).toEqual(['hello', '\r']);
} finally {
await session.stop(false);
}
});
+380
View File
@@ -0,0 +1,380 @@
/** @fileoverview Guarded staging one-shot and content-verification safety. */
import { beforeEach, describe, expect, it } from 'vitest';
import { createHash } from 'node:crypto';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
import {
GuardedStageStore,
STAGE_SETTLE_INTERVAL_MS,
STAGE_SETTLE_TIMEOUT_MS,
SUBMIT_QUIET_MS,
composerTriggerRefusal,
guardedComposerChrome,
readGuardedComposer,
readGuardedSnapshot,
type GuardedInputTarget,
} from '../src/agent-staging.js';
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */
const CODEX = guardedComposerChrome(getCli('codex'))!;
const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX);
const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX);
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
let store: GuardedStageStore;
let time: number;
let generation: string;
let revision: number;
let composer: string | null;
let writes: string[];
let fail: boolean;
let target: GuardedInputTarget;
/** Every settle-window wait, on the fake clock: no real timer runs in this file. */
let sleeps: number[];
const fakeSleep = async (ms: number) => {
sleeps.push(ms);
time += ms;
};
beforeEach(() => {
sleeps = [];
time = 0;
generation = 'pane-1';
revision = 0;
composer = '';
writes = [];
fail = false;
store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep });
target = {
id: 's',
chrome: () => CODEX,
snapshot: () =>
composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 },
submit: async () => target.write('\r'),
write: async (text) => {
writes.push(text);
revision++;
if (fail) return false;
composer = text === '\r' ? '' : composer + text;
return true;
},
};
});
it('stages without Enter, stores only a hash, and submits exactly once', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
expect(writes).toEqual(['hello']);
expect(receipt.sha256).toBe(sha('hello'));
expect(JSON.stringify(receipt)).not.toContain('hello');
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(writes).toEqual(['hello', '\r']);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
});
it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => {
for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800'])
await expect(store.stage(target, 'alice', text)).rejects.toThrow();
composer = 'existing';
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
composer = null;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
expect(writes).toEqual([]);
});
it('refuses altered composer including whitespace-only differences', async () => {
const receipt = await store.stage(target, 'alice', 'hello world');
composer = 'helloworld';
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
expect(writes).toEqual(['hello world']);
});
it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => {
for (const change of [
() => {
generation = 'pane-2';
},
() => {
revision++;
},
() => {
time += 1001;
},
]) {
composer = '';
const receipt = await store.stage(target, 'alice', 'hello');
change();
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
}
composer = '';
const receipt = await store.stage(target, 'alice', 'hello');
await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow();
await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow();
expect(writes.filter((s) => s === '\r')).toHaveLength(0);
});
it('consumes before write so concurrent and failed deliveries never replay Enter', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
fail = true;
const results = await Promise.allSettled([
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
]);
expect(results.every((r) => r.status === 'rejected')).toBe(true);
expect(writes).toEqual(['hello', '\r']);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
});
it('does not issue a receipt on failed staging or intervening input during its write', async () => {
fail = true;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
fail = false;
composer = '';
const altered = {
...target,
write: async (s: string) => {
await target.write(s);
revision++;
return true;
},
};
await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow();
});
it('reads exact composer spaces and refuses wrapped or modal frames', async () => {
expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world');
expect(
readGuardedCodexComposer(
'› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts'
)
).toBe('');
expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull();
expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull();
expect(readGuardedCodexComposer('› hello\n')).toBeNull();
});
it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => {
const { formatPaneSnapshot } = await import('../src/tmux-manager.js');
const frame = (text: string, x: number, y = 0) =>
formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y });
expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello');
expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull();
expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull();
expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull();
expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull();
expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好');
expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello');
});
it('reserves receipt capacity before concurrent stages type', async () => {
store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep });
let release: (() => void) | undefined;
const firstTarget = {
...target,
write: async (text: string) => {
await new Promise<void>((resolve) => {
release = resolve;
});
return target.write(text);
},
};
const first = store.stage(firstTarget, 'alice', 'hello');
const otherTarget = { ...target, id: 'other' };
await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i);
expect(writes).toEqual([]);
release!();
await first;
});
it.each([...CODEX_REASONING_EFFORTS, 'default'])(
'accepts the shared Codex effort word %s in the footer',
async (word) => {
expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello');
}
);
it('refuses messages that reach the available pane columns before typing', async () => {
target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 });
for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow();
expect(writes).toEqual([]);
await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false });
});
it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => {
const codex = getCli('codex')!.capabilities.workDetect!;
expect(CODEX.promptGlyph).toBe(codex.promptGlyph);
expect(CODEX.footerLine.source).toBe(codex.composerFooterLine);
// Another glyph drives the same reader: nothing in it is codex-specific.
const other = { promptGlyph: '>>', footerLine: CODEX.footerLine };
expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello');
expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull();
const declaring = listClis()
.filter((entry) => entry.capabilities.workDetect?.composerFooterLine)
.map((entry) => entry.id);
expect(declaring).toEqual(['codex']);
expect(guardedComposerChrome(getCli('claude'))).toBeNull();
// A pattern the ReDoS guard refuses yields no chrome, so no guarded input.
const unsafe = structuredClone(getCli('codex')!);
unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b';
expect(guardedComposerChrome(unsafe)).toBeNull();
});
describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => {
/** Serve these post-write frames in order, then the target's own snapshot. */
function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) {
let written = false;
const own = target.snapshot;
const write = target.write;
target.write = async (text) => {
const ok = await write(text);
written = true;
return ok;
};
target.snapshot = () => {
if (!written || frames.length === 0) return own();
const next = frames.shift()!;
return next ? next() : null;
};
}
const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 });
it('names its bounds', () => {
expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000);
expect(STAGE_SETTLE_INTERVAL_MS).toBe(50);
});
it('issues a receipt once a null first frame is followed by a valid one', async () => {
afterWrite([null]);
const receipt = await store.stage(target, 'alice', 'hello');
expect(receipt.sha256).toBe(sha('hello'));
expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]);
});
it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => {
afterWrite([stale, stale]);
await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false });
expect(sleeps).toHaveLength(2);
});
it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => {
target.snapshot = () => (writes.length ? null : stale());
const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message);
expect(refusal).toMatch(/within 1000 ms; no receipt was issued/);
expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/);
expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS);
});
it('issues no receipt for a frame whose composer text differs from the message', async () => {
const write = target.write;
target.write = async (text) => {
await write(text);
composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key
return true;
};
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/);
// Nothing to submit: no receipt was stored for the frame that did not match.
composer = 'hello';
await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow(
/Unknown/
);
});
it('refuses at once, without waiting, when another writer moved the input during the window', async () => {
afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]);
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
/Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/
);
expect(sleeps).toEqual([]);
});
it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => {
fail = true;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
/^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/
);
});
});
describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => {
const footer = ' gpt-5.6-terra default · ~/case';
/** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */
const frame = (popup: string[], draft: string) =>
['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n');
it('reads the idle frame with transcript above it', () => {
expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello');
});
it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => {
const popup = [
'› /status show current session configuration and token usage',
' /statusline configure which items appear in the status line',
];
expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull();
// A wrapped description row at the bottom of the list does not hide it.
expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull();
});
it('refuses the skill, mention and file-search popups', () => {
const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close'];
const mention = [
' Mentions',
' All Results Filesystem Only Plugins',
' sa',
'› Sample Plugin Plugin with skills and an MCP server Plugin',
' enter/tab insert · esc close · ↑/↓ select · ←/→ filter',
];
expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull();
expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull();
expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull();
expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull();
});
it('looks only at the block directly above the composer, not the transcript further up', () => {
const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n');
expect(readGuardedCodexComposer(screen)).toBe('hello');
});
it('takes the popup pattern and the trigger characters from the registry', () => {
const codex = getCli('codex')!.capabilities.workDetect!;
expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source);
expect(CODEX.commandPrefixes).toBe('/!');
expect(CODEX.completionSigils).toBe('@$');
// A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it.
const unsafe = structuredClone(getCli('codex')!);
unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b';
expect(guardedComposerChrome(unsafe)).toBeNull();
});
it.each([
['/stat', '/'],
['/status now', '/'],
[' /review', '/'],
['!ls -la', '!'],
['review @src/app', '@'],
['@notes', '@'],
['run $skill', '$'],
['costs $5', '$'],
])('refuses %j before typing (trigger %s)', async (message, trigger) => {
expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`);
await expect(store.stage(target, 'alice', message)).rejects.toThrow(
trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/
);
expect(writes).toEqual([]);
});
it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])(
'stages %j: no trigger at the start, and the caret word opens nothing',
async (message) => {
expect(composerTriggerRefusal(message, CODEX)).toBeNull();
await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false });
}
);
});
describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => {
it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => {
expect(SUBMIT_QUIET_MS).toBeGreaterThan(120);
const receipt = await store.stage(target, 'alice', 'hello');
time += 40;
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]);
expect(writes).toEqual(['hello', '\r']);
});
it('does not wait once the window has passed', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
time += SUBMIT_QUIET_MS;
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(sleeps).toEqual([]);
});
it('still refuses when a keystroke lands during the wait', async () => {
store = new GuardedStageStore(() => time, 2, 1000, {
sleep: async (ms) => {
await fakeSleep(ms);
revision++; // the user typed while submit waited
},
});
const receipt = await store.stage(target, 'alice', 'hello');
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(
/changed; nothing submitted/
);
expect(writes).toEqual(['hello']);
});
});
+42
View File
@@ -12,6 +12,7 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
@@ -195,6 +196,47 @@ describe('App Settings modal structure', () => {
}
});
/**
* Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because
* the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number.
* Off by default, and the interval clamped on save to the same bounds the schema accepts.
*/
it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => {
const modal = settingsModal();
const bar = modal.match(/<h4>Bottom bar<\/h4>([\s\S]*?)(?=<div class="set-group-head">)/)?.[1];
expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy();
for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) {
const at = modal.indexOf(`id="${id}"`);
expect(at, `no element ${id}`).toBeGreaterThan(-1);
const row = modal.slice(modal.lastIndexOf('<div class="set-row"', at), modal.indexOf('</div>', at));
expect(row).toContain('<span class="set-scope">synced</span>');
}
// The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects.
expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"');
// Display keys are the half of the per-device split that keeps a value off the server.
const displayStart = settingsUi.indexOf('const displayKeys = new Set([');
const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart));
expect(displayBlock).not.toContain('gitFetchEnabled');
expect(displayBlock).not.toContain('gitFetchIntervalSeconds');
// The strip destructure is the other half: an aliased key here never reaches the settings PUT.
expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/);
// Declared in the .strict() schema: an undeclared key 400s the whole settings PUT.
expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true);
// Off by default (only an explicit true loads as on), and an empty box falls back to the default interval.
expect(settingsUi).toContain(
"document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;"
);
expect(settingsUi).toContain(
"document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;"
);
expect(settingsUi).toMatch(
/gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/
);
});
it('has retired the modal-tab chrome everywhere, not just here', () => {
// Session Options and Add Case moved onto this same `set-*` surface, so the
// old tab classes have no users left. A reappearance means a modal drifted
+2 -1
View File
@@ -15,6 +15,7 @@ import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
@@ -46,7 +47,7 @@ async function getSessionCookie(baseUrl: string): Promise<string> {
}
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
for (let i = 0; i < 10; i++) {
for (let i = 0; i < AUTH_FAILURE_MAX; i++) {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
});
+181
View File
@@ -0,0 +1,181 @@
/**
* @fileoverview The Case settings "Default session group" selector (session-ui.js):
* it lists Ungrouped plus every named group the server reports, saves through
* PUT /api/cases/:name/default-group, reverts on a refused save, mirrors the
* desktop and phone popovers, and refreshes its options when the tab layout
* changes (groups created, renamed or deleted). Also pins the zh-CN strings.
*
* Port: none (vm sandbox, stubbed fetch and DOM).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
function fakeSelect() {
const select: any = {
value: '',
disabled: false,
options: [] as any[],
replaceChildren(...nodes: any[]) {
select.options = nodes;
},
};
return select;
}
function harness(server: { groups: Array<{ id: string; name: string }>; groupId: string | null; putStatus?: number }) {
const elements: Record<string, any> = {
quickStartCase: { value: 'proj' },
caseDefaultGroup: fakeSelect(),
caseDefaultGroupMobile: fakeSelect(),
};
const requests: Array<{ url: string; method: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: (id: string) => elements[id] ?? null,
createElement: () => {
const attrs: Record<string, string> = {};
return { value: '', textContent: '', attrs, setAttribute: (k: string, v: string) => (attrs[k] = v) };
},
},
fetch: async (url: string, init?: { method?: string; body?: string }) => {
const method = init?.method ?? 'GET';
const body = init?.body ? JSON.parse(init.body) : undefined;
requests.push({ url, method, body });
const ok = method !== 'PUT' || (server.putStatus ?? 200) === 200;
if (method === 'PUT' && ok) server.groupId = body.groupId;
const payload = ok
? { success: true, data: { caseName: 'proj', groupId: server.groupId, groups: server.groups } }
: { success: false, error: 'nope', errorCode: 'INVALID_INPUT' };
return { ok, status: ok ? 200 : (server.putStatus ?? 400), json: async () => payload };
},
console,
});
for (const file of ['api-client.js', 'session-ui.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.showToast = vi.fn();
return { app, elements, requests, server };
}
const optionView = (select: any) => select.options.map((o: any) => [o.value, o.textContent]);
describe('Case settings: Default session group selector', () => {
it('lists Ungrouped plus every named group, selecting the stored one in both popovers', async () => {
const h = harness({
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
groupId: 'g2',
});
await h.app._loadCaseDefaultGroup();
expect(h.requests[0]).toMatchObject({ url: '/api/cases/proj/default-group', method: 'GET' });
for (const id of ['caseDefaultGroup', 'caseDefaultGroupMobile']) {
const select = h.elements[id];
expect(optionView(select)).toEqual([
['', 'Ungrouped'],
['g1', 'Core'],
['g2', 'Ops'],
]);
expect(select.value).toBe('g2');
expect(select.disabled).toBe(false);
// Group names are user text: never handed to the translator.
expect(select.options[1].attrs['data-i18n-skip']).toBe('');
expect(select.options[0].attrs['data-i18n-skip']).toBeUndefined();
}
});
it('saves a choice (and Ungrouped as null) and mirrors it to the other popover', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: null });
await h.app._loadCaseDefaultGroup();
h.elements.caseDefaultGroup.value = 'g1';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
expect(h.requests.at(-1)).toEqual({ url: '/api/cases/proj/default-group', method: 'PUT', body: { groupId: 'g1' } });
expect(h.elements.caseDefaultGroupMobile.value).toBe('g1');
h.elements.caseDefaultGroupMobile.value = '';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroupMobile);
expect(h.requests.at(-1)?.body).toEqual({ groupId: null });
expect(h.elements.caseDefaultGroup.value).toBe('');
});
it('reverts to the confirmed value and says so when the save is refused', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1', putStatus: 400 });
await h.app._loadCaseDefaultGroup();
h.elements.caseDefaultGroup.value = '';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
expect(h.elements.caseDefaultGroup.value).toBe('g1');
expect(h.app.showToast).toHaveBeenCalledWith('Could not save the default session group.', 'error');
});
it('refreshes its options when the layout changes: created, renamed and deleted groups', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1' });
await h.app._loadCaseDefaultGroup();
h.app._refreshCaseDefaultGroupOptions({
groups: [
{ id: 'g1', name: 'Core team' },
{ id: 'g3', name: 'New' },
],
});
expect(optionView(h.elements.caseDefaultGroup)).toEqual([
['', 'Ungrouped'],
['g1', 'Core team'],
['g3', 'New'],
]);
expect(h.elements.caseDefaultGroup.value).toBe('g1');
// The selected group is deleted: the selector visibly falls back to Ungrouped.
h.app._refreshCaseDefaultGroupOptions({ groups: [{ id: 'g3', name: 'New' }] });
expect(h.elements.caseDefaultGroup.value).toBe('');
expect(h.elements.caseDefaultGroupMobile.value).toBe('');
});
it('leaves the options alone when a layout broadcast does not change the groups', async () => {
const h = harness({
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
groupId: 'g2',
});
await h.app._loadCaseDefaultGroup();
const before = h.elements.caseDefaultGroup.options;
// A session created or moved elsewhere: same groups, other refs.
h.app._refreshCaseDefaultGroupOptions({
groups: [
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's9' }] },
{ id: 'g2', name: 'Ops', refs: [] },
],
});
expect(h.elements.caseDefaultGroup.options).toBe(before); // not rebuilt, so an open dropdown stays open
expect(h.elements.caseDefaultGroup.value).toBe('g2');
});
it('the layout refresh is wired into _applyTabLayout', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const body = app.slice(app.indexOf(' _applyTabLayout(layout) {'));
expect(body.slice(0, body.indexOf('\n }\n'))).toMatch(/^\s*this\._refreshCaseDefaultGroupOptions\?\.\(next\);/m);
});
it('both popovers carry the selector, and every new string has a zh-CN entry', () => {
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
expect(html).toContain('id="caseDefaultGroup"');
expect(html).toContain('id="caseDefaultGroupMobile"');
const i18n = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
for (const text of [
'Default session group',
'New sessions for this case land in this group',
'Could not save the default session group.',
'Ungrouped',
]) {
expect(i18n, text).toMatch(new RegExp(`['"]?${text.replace(/[.]/g, '\\.')}['"]?: '`));
}
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* @fileoverview Per-case default session group: the state.json store and the
* TabLayoutService placement precedence (explicit group > parent > case default > none).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { StateStore } from '../src/state-store.js';
import { TabLayoutService } from '../src/tab-layout-service.js';
import { TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js';
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string };
describe('StateStore case default groups', () => {
let dir: string;
let file: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-case-default-'));
file = join(dir, 'state.json');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('distinguishes unset from an intentional Ungrouped choice, per owner', () => {
const store = new StateStore(file);
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
store.setCaseDefaultGroup('@single', 'app', 'g1');
store.setCaseDefaultGroup('@single', 'other', null);
store.setCaseDefaultGroup('bob', 'app', 'g9');
expect(store.getCaseDefaultGroup('@single', 'app')).toBe('g1');
expect(store.getCaseDefaultGroup('@single', 'other')).toBeNull();
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g9');
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
it('survives a save and reload (persistence round-trip)', () => {
const store = new StateStore(file);
store.setCaseDefaultGroup('@single', 'app', 'g1');
store.setCaseDefaultGroup('@single', 'none', null);
store.saveNow();
const disk = JSON.parse(readFileSync(file, 'utf-8'));
expect(disk.caseDefaultGroups).toEqual({ '@single': { app: 'g1', none: null } });
const reloaded = new StateStore(file);
expect(reloaded.getCaseDefaultGroup('@single', 'app')).toBe('g1');
expect(reloaded.getCaseDefaultGroup('@single', 'none')).toBeNull();
});
it('forgets one owner mapping without touching another owner', () => {
const store = new StateStore(file);
store.setCaseDefaultGroup('alice', 'app', 'g1');
store.setCaseDefaultGroup('bob', 'app', 'g2');
store.deleteCaseDefaultGroup('alice', 'app');
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g2');
});
it('reads a malformed on-disk value as unset instead of throwing', () => {
writeFileSync(
file,
JSON.stringify({ sessions: {}, caseDefaultGroups: { '@single': { app: 42, ok: 'g1' }, bad: 'x' } })
);
const store = new StateStore(file);
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('@single', 'ok')).toBe('g1');
expect(store.getCaseDefaultGroup('bad', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('@single', '__proto__')).toBeUndefined();
});
});
function createHarness(options: { layouts?: Record<string, TabLayout>; live?: SessionFact[]; follow?: boolean } = {}) {
const layouts = { ...(options.layouts ?? {}) };
const live = new Map((options.live ?? []).map((session) => [session.id, session]));
const order: string[] = [];
const defaults: Record<string, Record<string, string | null>> = {};
const caseDefaults = {
getCaseDefaultGroup: vi.fn((owner: string, caseName: string) =>
defaults[owner] && caseName in defaults[owner] ? defaults[owner][caseName] : undefined
),
setCaseDefaultGroup: vi.fn((owner: string, caseName: string, groupId: string | null) => {
defaults[owner] = { ...(defaults[owner] ?? {}), [caseName]: groupId };
}),
deleteCaseDefaultGroup: vi.fn((owner: string, caseName: string) => {
if (defaults[owner]) delete defaults[owner][caseName];
}),
};
const store = {
getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null),
getTabLayouts: vi.fn(() => ({ ...layouts })),
getSessions: vi.fn(() => ({})),
getSessionOrder: vi.fn(() => [...order]),
commitTabLayoutProjection: vi.fn(
(updates: Readonly<Record<string, TabLayout>>, project: (latest: readonly string[]) => readonly string[]) => {
const projected = [...project([...order])];
Object.assign(layouts, structuredClone(updates));
order.splice(0, order.length, ...projected);
return { layouts: structuredClone(updates), sessionOrder: [...order] };
}
),
};
const service = new TabLayoutService({
store: store as never,
sessions: live as never,
readWebviews: vi.fn(async () => []),
broadcast: vi.fn(),
broadcastSessionOrder: vi.fn(),
childrenFollowParent: vi.fn(() => options.follow === true),
caseDefaults,
now: () => '2026-10-22T12:00:00.000Z',
});
const register = (fact: SessionFact) =>
vi.fn(() => {
live.set(fact.id, fact);
return () => live.delete(fact.id);
});
return { service, store, layouts, live, defaults, caseDefaults, register };
}
const grouped = (version = 7): TabLayout => ({
version,
groups: [
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 'a' }] },
{ id: 'g2', name: 'Ops', refs: [] },
],
ungrouped: [{ kind: 'session', id: 'b' }],
updatedAt: '2026-10-21T00:00:00.000Z',
});
const facts = (owner = 'alice'): SessionFact[] => [
{ id: 'a', owner, createdAt: 1 },
{ id: 'b', owner, createdAt: 2 },
];
const idsIn = (layout: TabLayout, groupId: string | null): string[] =>
(groupId === null ? layout.ungrouped : (layout.groups.find((g) => g.id === groupId)?.refs ?? [])).map((r) => r.id);
describe('TabLayoutService case default placement', () => {
it('places a session for the case into its default group in the creating write', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g2')).toEqual(['new']);
expect(idsIn(layout, null)).toEqual(['b']);
expect(layout.version).toBe(8);
// One versioned write: no transient Ungrouped layout was ever committed.
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
});
it('an explicit tabGroupId wins over the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app', tabGroupId: 'g1' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g1')).toEqual(['a', 'new']);
expect(idsIn(layout, 'g2')).toEqual([]);
});
it('an unknown explicit group falls through to the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app', tabGroupId: 'gone' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g2')).toEqual(['new']);
});
it.each([true, false])('the parent placement wins over the case default (follow=%s)', async (follow) => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts(), follow });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'child',
{ caseName: 'app' },
h.register({ id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' })
);
expect(idsIn(layout, 'g1')).toEqual(['a', 'child']);
expect(idsIn(layout, 'g2')).toEqual([]);
});
it('a parent that is not in the layout does not block the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'orphan',
{ caseName: 'app' },
h.register({ id: 'orphan', owner: 'alice', createdAt: 3, parentSessionId: 'nobody' })
);
expect(idsIn(layout, 'g2')).toEqual(['orphan']);
});
it('an intentional Ungrouped default (null) or no default leaves the session ungrouped', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: null };
const first = await h.service.sessionCreated(
'alice',
'n1',
{ caseName: 'app' },
h.register({ id: 'n1', owner: 'alice', createdAt: 3 })
);
expect(idsIn(first, null)).toEqual(['b', 'n1']);
const second = await h.service.sessionCreated(
'alice',
'n2',
{ caseName: 'unset' },
h.register({ id: 'n2', owner: 'alice', createdAt: 4 })
);
expect(idsIn(second, null)).toEqual(['b', 'n1', 'n2']);
});
it('a deleted default group never fails creation: Ungrouped, and the stale value is cleared', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'deleted-group' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, null)).toEqual(['b', 'new']);
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
it("never applies another owner's group id", async () => {
const h = createHarness({
layouts: { alice: grouped(), bob: { ...grouped(3), groups: [], ungrouped: [] } },
live: [...facts()],
});
// A value naming alice's group, stored under bob, is not found in bob's layout.
h.defaults.bob = { app: 'g1' };
const layout = await h.service.sessionCreated(
'bob',
'bob-new',
{ caseName: 'app' },
h.register({ id: 'bob-new', owner: 'bob', createdAt: 5 })
);
expect(layout.groups).toEqual([]);
expect(idsIn(layout, null)).toEqual(['bob-new']);
expect(idsIn(h.layouts.alice, 'g1')).toEqual(['a']);
});
it('a renamed group keeps receiving sessions (the default is the stable id)', async () => {
const renamed = grouped();
renamed.groups[1] = { ...renamed.groups[1], name: 'Operations' };
const h = createHarness({ layouts: { alice: renamed }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(layout.groups[1]).toMatchObject({ id: 'g2', name: 'Operations' });
expect(idsIn(layout, 'g2')).toEqual(['new']);
});
it('changing the default never moves an existing session', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
await h.service.sessionCreated(
'alice',
's1',
{ caseName: 'app' },
h.register({ id: 's1', owner: 'alice', createdAt: 3 })
);
await h.service.setCaseDefaultGroup('alice', 'app', 'g1');
const layout = await h.service.sessionCreated(
'alice',
's2',
{ caseName: 'app' },
h.register({ id: 's2', owner: 'alice', createdAt: 4 })
);
expect(idsIn(layout, 'g2')).toEqual(['s1']);
expect(idsIn(layout, 'g1')).toEqual(['a', 's2']);
});
it('a failed layout write rolls the registration back and writes no case default change', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'deleted-group' };
h.store.commitTabLayoutProjection.mockImplementationOnce(() => {
throw new Error('disk full');
});
const added = h.register({ id: 'new', owner: 'alice', createdAt: 3 });
await expect(h.service.sessionCreated('alice', 'new', { caseName: 'app' }, added)).rejects.toThrow('disk full');
expect(h.live.has('new')).toBe(false);
expect(h.layouts.alice).toEqual(grouped());
expect(h.caseDefaults.setCaseDefaultGroup).not.toHaveBeenCalled();
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
});
});
describe('TabLayoutService case default reads and writes', () => {
it('reports the selection with the owner group options, and a stale id as Ungrouped without writing', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
expect(await h.service.caseDefaultGroup('alice', 'app')).toEqual({
groupId: null,
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
});
h.defaults.alice = { app: 'gone' };
expect((await h.service.caseDefaultGroup('alice', 'app')).groupId).toBeNull();
// A read never writes: the next creation for the case is what forgets the stale id.
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBe('gone');
});
it('saves a known group or null, and refuses an id that is not in the owner layout', async () => {
const h = createHarness({ layouts: { alice: grouped(), bob: { ...grouped(1), groups: [] } }, live: facts() });
await h.service.setCaseDefaultGroup('alice', 'app', 'g2');
expect(h.defaults.alice.app).toBe('g2');
await h.service.setCaseDefaultGroup('alice', 'app', null);
expect(h.defaults.alice.app).toBeNull();
await expect(h.service.setCaseDefaultGroup('bob', 'app', 'g1')).rejects.toBeInstanceOf(TabLayoutValidationError);
expect(h.defaults.bob).toBeUndefined();
});
it('forgets a mapping when its case goes away', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g1' };
await h.service.forgetCaseDefaultGroup('alice', 'app');
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
});
+16 -1
View File
@@ -54,14 +54,29 @@ describe('blockedReason', () => {
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
it('refuses a cases directory and its direct children, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toContain('/home/u/codeman-cases');
// Two messages: the cases folder is not "in" itself, and a child that does not exist yet is not
// "already" listed.
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/cases folder itself/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/directly in the cases folder/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).not.toMatch(/already/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('allows a folder deeper than a direct child of the cases directory (it is not listed as a local case)', () => {
// CODEMAN_CASES_PATH can be a broad root such as /mnt/user/Scripts; GET /api/cases lists only its
// direct children, so a folder further down can be linked without appearing twice.
const broad = { ...c, casesDirs: ['/mnt/user/Scripts'] };
expect(blockedReason('/mnt/user/Scripts/GitHub', broad)).toMatch(/plain Create New/);
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454', broad)).toBeNull();
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454/app', broad)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
+103
View File
@@ -0,0 +1,103 @@
/**
* @fileoverview A case rename racing a session create, against the real WebServer.
*
* A create constructs its `Session` (which checks the rename lock) and only puts it in the server's session
* map later, after awaiting the owner's layout lock and a settings read. A rename that ran in that gap built its
* list of open sessions without the new one, moved the folder, and the create then registered a session whose
* pane would `cd` into a path that no longer existed. The rename now refuses the move while such a session is
* still being created, and the server's registration is what lets go of it.
*
* The gap is widened by holding the settings read inside `TabLayoutService.sessionCreated()`
* (`childrenFollowParent`), the await between the constructor and the registration callback.
*
* Port: 0 (ephemeral, read back from `server.boundPort`).
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { isCaseSessionStarting } from '../src/case-rename.js';
import { safeRmHomeTree } from './mocks/test-helpers.js';
interface LayoutDeps {
childrenFollowParent?: () => Promise<boolean>;
}
let server: WebServer;
const made: string[] = [];
const url = (path: string) => `http://localhost:${server.boundPort}${path}`;
const post = async (path: string, body: unknown) => {
const res = await fetch(url(path), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, json: (await res.json()) as Record<string, any> };
};
const localCase = (name: string) => {
const dir = join(homedir(), 'codeman-cases', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'CLAUDE.md'), name);
made.push(dir);
return dir;
};
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
}, 60000);
afterAll(async () => {
await server?.stop();
for (const dir of made.splice(0)) safeRmHomeTree(dir);
}, 60000);
describe('renaming a case while a session is being created in it', () => {
it('refuses the move until the session is registered, then lists it like any open session', async () => {
const dir = localCase('race-alpha');
made.push(join(homedir(), 'codeman-cases', 'race-beta')); // only there if a rename got through
const deps = (server as unknown as { tabLayouts: { deps: LayoutDeps } }).tabLayouts.deps;
const original = deps.childrenFollowParent;
let reached!: () => void;
const atGap = new Promise<void>((resolve) => (reached = resolve));
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
deps.childrenFollowParent = async () => {
reached();
await gate;
return (await original?.()) === true;
};
try {
const create = post('/api/sessions', { workingDir: dir, mode: 'shell' });
await atGap; // constructed, not yet in the server's session map
expect(isCaseSessionStarting(dir)).toBe(true);
// Without the refusal the rename moves the folder and then waits on the owner's layout lock, which the
// held create owns: bound the wait so that shows up as a failure rather than a test timeout.
const busy = await Promise.race([
post('/api/cases/race-alpha/rename', { newName: 'race-beta', stopSessions: true }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
]);
expect(existsSync(dir), 'the folder moved under the session being created').toBe(true);
expect(busy?.status).toBe(409);
expect(busy?.json).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
deps.childrenFollowParent = original;
release();
const created = await create;
expect(created.status).toBe(200);
const id = created.json.data.session.id as string;
expect(isCaseSessionStarting(dir)).toBe(false);
const listed = await post('/api/cases/race-alpha/rename', { newName: 'race-beta' });
expect(listed.status).toBe(409);
expect(listed.json).toMatchObject({ requiresStop: true, sessions: [{ id }] });
expect(existsSync(dir)).toBe(true);
await fetch(url(`/api/sessions/${id}`), { method: 'DELETE' });
} finally {
deps.childrenFollowParent = original;
release();
}
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* @fileoverview The Rename dialog's flow in session-ui.js (renameCase / submitRenameCase): it is the app's
* own modal, not a browser prompt; a linked case offers the folder checkbox; live sessions are listed inside
* the dialog and only stopped after a second press; server errors stay in the dialog. Evaluates the real file
* against a stub DOM.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
const HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
type El = {
value: string;
textContent: string;
checked: boolean;
disabled: boolean;
classes: Set<string>;
classList: {
add(c: string): void;
remove(c: string): void;
toggle(c: string, on?: boolean): void;
contains(c: string): boolean;
};
focus(): void;
select(): void;
};
const el = (hidden = false): El => {
const classes = new Set<string>(hidden ? ['hidden'] : []);
return {
value: '',
textContent: '',
checked: false,
disabled: false,
classes,
classList: {
add: (c) => void classes.add(c),
remove: (c) => void classes.delete(c),
toggle: (c, on) => void ((on ?? !classes.has(c)) ? classes.add(c) : classes.delete(c)),
contains: (c) => classes.has(c),
},
focus() {},
select() {},
};
};
function makeApp(opts: { responses: unknown[]; cases: unknown[]; current?: string }) {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const toasts: string[] = [];
const store = new Map<string, string>([['caseSettings_alpha', '{"agentTeams":true}']]);
const queue = [...opts.responses];
const ids = [
'renameCaseModal',
'renameCaseInput',
'renameCaseLabel',
'renameCaseFolderRow',
'renameCaseFolder',
'renameCaseFolderHint',
'renameCaseNote',
'renameCaseError',
'renameCaseSubmit',
'quickStartCase',
];
const els: Record<string, El> = Object.fromEntries(ids.map((id) => [id, el()]));
els.quickStartCase.value = opts.current ?? 'alpha';
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => els[id] ?? null },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
fetch: async (url: string, init: { body: string }) => {
calls.push({ url, body: JSON.parse(init.body) });
return { json: async () => queue.shift() };
},
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
const refreshed: unknown[] = [];
Object.assign(app, {
cases: opts.cases,
showToast: (m: string) => void toasts.push(m),
loadQuickStartCases: async (n: unknown) => void refreshed.push(n),
renderCaseManageList: () => void refreshed.push('render'),
});
return { app, els, calls, toasts, store, refreshed };
}
const ok = { success: true, data: {} };
describe('the Rename dialog is part of the page, not a browser prompt', () => {
it('has its modal in index.html, with the name, folder and error elements the code drives', () => {
for (const id of [
'renameCaseModal',
'renameCaseInput',
'renameCaseFolder',
'renameCaseError',
'renameCaseSubmit',
]) {
expect(HTML).toContain(`id="${id}"`);
}
const fn = SRC.slice(SRC.indexOf('renameCase(name)'), SRC.indexOf('async deleteCase(name)'));
expect(fn).not.toMatch(/\b(prompt|confirm|alert)\(/);
});
});
describe('renameCase / submitRenameCase', () => {
it('opens pre-filled, renames a local case, moves its run options and reselects it under the new name', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', location: 'local' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseInput.value).toBe('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(true);
t.els.renameCaseInput.value = ' beta ';
await t.app.submitRenameCase();
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
expect(t.store.has('caseSettings_alpha')).toBe(false);
expect(t.refreshed).toEqual(['beta', 'render']);
});
it('keeps the dialog open with a message for the same name or a name that is not allowed, and sends nothing', async () => {
for (const entered of ['', 'alpha', 'a b', '../x']) {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = entered;
await t.app.submitRenameCase();
expect(t.calls, entered).toEqual([]);
expect(t.els.renameCaseError.classes.has('hidden'), entered).toBe(false);
expect(t.els.renameCaseModal.classes.has('active'), entered).toBe(true);
}
});
it('shows the folder checkbox for a linked case and sends its state', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(false);
expect(t.els.renameCaseFolderHint.textContent).toContain('/srv/app');
t.els.renameCaseInput.value = 'beta';
t.els.renameCaseFolder.checked = true;
await t.app.submitRenameCase();
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
const alias = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
alias.app.renameCase('alpha');
alias.els.renameCaseInput.value = 'beta';
await alias.app.submitRenameCase();
expect(alias.calls[0].body).toEqual({ newName: 'beta', renameFolder: false });
});
it('lists the live sessions in the dialog and stops them only on the second press', async () => {
const refusal = {
success: false,
error: '1 live session has this folder open.',
requiresStop: true,
sessions: [{ id: 's', name: 'w1-alpha' }],
};
const t = makeApp({ responses: [refusal, ok], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toContain('w1-alpha');
// "stop" closes them through the server's session cleanup: say that their tabs go away
expect(t.els.renameCaseError.textContent).toContain('They will be closed (their tabs go away');
expect(t.els.renameCaseSubmit.textContent).toBe('Stop sessions and rename');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.calls).toHaveLength(1);
await t.app.submitRenameCase();
expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
});
it('says how many sessions were already closed when the rename fails after closing them', async () => {
const refusal = { success: false, error: 'busy', requiresStop: true, sessions: [{ id: 's', name: 'w1-alpha' }] };
const failed = { success: false, error: 'The registry could not be written.', stoppedSessions: 2 };
const t = makeApp({ responses: [refusal, failed], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe(
'The registry could not be written. (2 sessions were already closed.)'
);
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
});
it('cancelling the dialog stops nothing and renames nothing', async () => {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.app.closeRenameCase();
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
await t.app.submitRenameCase(); // nothing open: ignored
expect(t.calls).toEqual([]);
});
it('shows the server’s message inside the dialog when the folder is locked, and leaves the lists alone', async () => {
const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' };
const t = makeApp({ responses: [locked], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe('The folder is in use by another program (EBUSY).');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseSubmit.disabled).toBe(false); // the name can be fixed and tried again
expect(t.toasts).toEqual([]);
expect(t.refreshed).toEqual([]);
expect(t.store.has('caseSettings_alpha')).toBe(true);
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* @fileoverview src/case-rename.ts: the in-process lock, the record of sessions still being created, the
* retried folder move and Claude history migration. Real filesystem under test/setup.ts's temp HOME. Port: N/A.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CaseRenameError,
caseSessionSettled,
claudeProjectDirName,
isCasePathLocked,
isCaseSessionStarting,
moveCaseFolder,
noteCaseSessionStarting,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../src/case-rename.js';
import { Session } from '../src/session.js';
const roots: string[] = [];
const tmp = () => {
const dir = mkdtempSync(join(tmpdir(), 'case-rename-'));
roots.push(dir);
return dir;
};
afterEach(async () => {
vi.restoreAllMocks();
for (const dir of roots.splice(0)) await fsp.rm(dir, { recursive: true, force: true });
});
describe('withCaseLock', () => {
it('holds both paths and everything inside them for the length of the call, then lets go', async () => {
const root = tmp();
const from = join(root, 'old');
const to = join(root, 'new');
let during: boolean[] = [];
await withCaseLock(from, to, async () => {
during = [
isCasePathLocked(from),
isCasePathLocked(join(from, 'src')),
isCasePathLocked(to),
isCasePathLocked(root),
];
});
expect(during).toEqual([true, true, true, false]);
expect(isCasePathLocked(from)).toBe(false);
expect(isCasePathLocked(to)).toBe(false);
});
it('refuses an overlapping rename with BUSY and releases on failure', async () => {
const root = tmp();
await withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
await expect(withCaseLock(join(root, 'b'), join(root, 'c'), async () => {})).rejects.toMatchObject({
code: 'BUSY',
});
await expect(withCaseLock(join(root, 'a', 'inner'), join(root, 'x'), async () => {})).rejects.toBeInstanceOf(
CaseRenameError
);
});
await expect(
withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(isCasePathLocked(join(root, 'a'))).toBe(false);
});
it('keeps a session from starting in a folder that is being moved', async () => {
const root = tmp();
const from = join(root, 'case');
mkdirSync(from);
await withCaseLock(from, join(root, 'renamed'), async () => {
expect(() => new Session({ workingDir: from })).toThrow(/being renamed/);
expect(() => new Session({ workingDir: join(from, 'sub') })).toThrow(/being renamed/);
});
});
});
describe('sessions still being created', () => {
it('counts a constructed session as starting in its folder until the server settles it', () => {
const root = tmp();
const dir = join(root, 'case');
const session = new Session({ workingDir: join(dir, 'sub') });
expect(isCaseSessionStarting(dir)).toBe(true);
expect(isCaseSessionStarting(join(dir, 'sub'))).toBe(true);
expect(isCaseSessionStarting(join(dir, 'other'))).toBe(false);
expect(isCaseSessionStarting(`${dir}-2`)).toBe(false);
caseSessionSettled(session);
expect(isCaseSessionStarting(dir)).toBe(false);
});
it('lets go of a session that is stopped, and of one never registered once a while has passed', async () => {
const root = tmp();
const stopped = new Session({ workingDir: root });
await stopped.stop();
expect(isCaseSessionStarting(root)).toBe(false);
// a create that failed before registering, or a one-shot prompt that never registers
const lost = {};
noteCaseSessionStarting(lost, root, 1000);
expect(isCaseSessionStarting(root, 1000 + 29_999)).toBe(true);
expect(isCaseSessionStarting(root, 1000 + 30_000)).toBe(false);
});
});
describe('moveCaseFolder', () => {
it('moves a folder and its contents', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
writeFileSync(join(root, 'old', 'CLAUDE.md'), 'x');
await moveCaseFolder(join(root, 'old'), join(root, 'new'));
expect(existsSync(join(root, 'old'))).toBe(false);
expect(existsSync(join(root, 'new', 'CLAUDE.md'))).toBe(true);
});
it('never replaces an existing folder, even an empty one that rename would silently overwrite', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
mkdirSync(join(root, 'taken'));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'taken'))).rejects.toMatchObject({ code: 'EXISTS' });
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('retries a lock error, and succeeds once the other program lets go', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
const real = fsp.rename.bind(fsp);
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async (a, b) => {
if (++calls < 3) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
return real(a, b);
});
await moveCaseFolder(join(root, 'old'), join(root, 'new'), 4, 1);
expect(calls).toBe(3);
expect(existsSync(join(root, 'new'))).toBe(true);
});
it('reports a lock that never clears as LOCKED, with what to do about it', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('perm'), { code: 'EPERM' }));
const err = await moveCaseFolder(join(root, 'old'), join(root, 'new'), 2, 1).catch((e) => e);
expect(err).toBeInstanceOf(CaseRenameError);
expect(err.code).toBe('LOCKED');
expect(err.message).toMatch(/in use by another program/);
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('refuses to cross filesystems', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('xdev'), { code: 'EXDEV' }));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'new'))).rejects.toMatchObject({ code: 'CROSS_DEVICE' });
});
});
describe('Claude history', () => {
it('names a project directory the way Claude Code does', () => {
expect(claudeProjectDirName('/home/devvyn/codeman-cases/my_case')).toBe('-home-devvyn-codeman-cases-my-case');
expect(claudeProjectDirName('/home/devvyn/.dotcase')).toBe('-home-devvyn--dotcase');
});
it('moves the old path’s history to the new one', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
writeFileSync(join(projects, claudeProjectDirName(oldPath), 'c.jsonl'), '{}');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(newPath), 'c.jsonl'))).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(false);
expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).toBe(false);
});
it('merges into history already at the new path (a deleted case’s leftovers) without overwriting any of it', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(join(from, 'conv-a', 'subagents'), { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
writeFileSync(join(from, 'conv-a', 'subagents', 'agent-1.jsonl'), 'alpha agent');
mkdirSync(join(from, 'memory'));
writeFileSync(join(from, 'memory', 'MEMORY.md'), 'alpha memory');
// Claude Code never removes a project directory, so a case deleted earlier under the new name left this.
mkdirSync(join(to, 'memory'), { recursive: true });
writeFileSync(join(to, 'conv-old.jsonl'), 'old beta');
writeFileSync(join(to, 'memory', 'MEMORY.md'), 'old beta memory');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(readFileSync(join(to, 'conv-a.jsonl'), 'utf8')).toBe('alpha');
expect(readFileSync(join(to, 'conv-a', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('alpha agent');
expect(readFileSync(join(to, 'conv-old.jsonl'), 'utf8')).toBe('old beta');
// an entry both have is never overwritten: it stays where it was
expect(readFileSync(join(to, 'memory', 'MEMORY.md'), 'utf8')).toBe('old beta memory');
expect(readFileSync(join(from, 'memory', 'MEMORY.md'), 'utf8')).toBe('alpha memory');
expect(existsSync(join(from, 'conv-a.jsonl'))).toBe(false);
});
it('removes the old project directory once a merge has emptied it, and never merges into a symlink', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(from, { recursive: true });
mkdirSync(to, { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(from)).toBe(false);
const elsewhere = tmp();
const linkedPath = join(tmp(), 'gamma');
mkdirSync(from);
writeFileSync(join(from, 'conv-b.jsonl'), 'alpha');
symlinkSync(elsewhere, join(projects, claudeProjectDirName(linkedPath)));
expect(await moveClaudeHistory(oldPath, linkedPath, cfg)).toBe(false);
expect(existsSync(join(from, 'conv-b.jsonl'))).toBe(true);
expect(existsSync(join(elsewhere, 'conv-b.jsonl'))).toBe(false);
});
});
describe('small helpers', () => {
it('renames in an order list keeping the position, and says nothing when absent', () => {
expect(renameInOrder(['a', 'b', 'c'], 'b', 'z')).toEqual(['a', 'z', 'c']);
expect(renameInOrder(['a'], 'q', 'z')).toBeNull();
expect(renameInOrder(undefined, 'a', 'z')).toBeNull();
});
it('puts the new name next to the old folder', () => {
expect(siblingPath('/srv/work/app', 'app2')).toBe('/srv/work/app2');
});
});
+91 -26
View File
@@ -1,45 +1,110 @@
/**
* @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one.
* @fileoverview The CLI's session name and the opt-in `pinCliSessionName` setting.
*
* `--name` sets the prompt-box label, the `/resume` picker entry and the terminal
* title, and a pinned title stops Claude generating its own. Pinning the
* `w1-myapp` placeholder therefore listed every conversation of a case under the
* same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and
* a rename reaches the transcript as a `custom-title` row.
* title, and a pinned title stops Claude generating its own. By default only a name
* the user chose is pinned (`cliPinnedName`); with `pinCliSessionName` on, every tab's
* name is pinned, placeholder and auto names included, so Claude's own session list
* matches the tab. The setting is re-read at each spawn and respawn. A rename reaches
* the transcript as a `custom-title` row.
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { appendClaudeCustomTitle } from '../src/claude-session-title.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } };
type RespawnOptionsProbe = {
_buildRespawnPaneOptions(): { name?: string; cliName?: string };
_buildRespawnPaneOptionsWithResumePin(): Promise<{ name?: string; cliName?: string }>;
};
const probe = (session: Session) => session as unknown as RespawnOptionsProbe;
/** The respawn builder is the path every respawn takes; it re-reads the setting first. */
const cliNameAtRespawn = async (session: Session) =>
(await probe(session)._buildRespawnPaneOptionsWithResumePin()).cliName;
function writeSettings(settings: Record<string, unknown> | null) {
if (settings === null) {
rmSync(SETTINGS_PATH, { force: true });
return;
}
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
}
describe('Session.cliPinnedName', () => {
it('pins nothing for a placeholder, so Claude titles the conversation itself', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.cliPinnedName).toBeUndefined();
const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions();
// The tab keeps its name; only the CLI flag is withheld.
expect(options.name).toBe('w1-demo');
expect(options.cliName).toBeUndefined();
afterEach(() => writeSettings(null));
describe('pinCliSessionName off (default)', () => {
it.each([null, {}, { pinCliSessionName: false }])(
'leaves a placeholder name unpinned (settings: %j)',
async (settings) => {
writeSettings(settings);
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(await cliNameAtRespawn(session)).toBeUndefined();
expect(session.cliPinnedName).toBeUndefined();
expect(probe(session)._buildRespawnPaneOptions().name).toBe('w1-demo');
}
);
it('leaves an auto name unpinned', async () => {
writeSettings({ pinCliSessionName: false });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(await cliNameAtRespawn(session)).toBeUndefined();
});
it('still pins a name the user chose, at creation or by a rename', async () => {
writeSettings({ pinCliSessionName: false });
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }))).toBe(
'msgtest-worker'
);
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
});
});
it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(session.cliPinnedName).toBeUndefined();
describe('pinCliSessionName on', () => {
it('pins the placeholder name, so the CLI session matches the tab', async () => {
writeSettings({ pinCliSessionName: true });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
expect(session.cliPinnedName).toBe('w1-demo');
});
it('pins an auto name too, from the next spawn', async () => {
writeSettings({ pinCliSessionName: true });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(await cliNameAtRespawn(session)).toBe('w1-demo: fix the login redirect');
});
it('pins a name the user chose', async () => {
writeSettings({ pinCliSessionName: true });
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
});
it('never pins a blank name', async () => {
writeSettings({ pinCliSessionName: true });
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: ' ' }))).toBeUndefined();
});
});
it('pins a name the user chose, at creation or by a rename', () => {
expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker');
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(renamed.cliPinnedName).toBe('登录修复');
expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复');
it('follows the setting on the next respawn of the same session, with no restart', async () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
writeSettings({ pinCliSessionName: true });
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
writeSettings({ pinCliSessionName: false });
expect(await cliNameAtRespawn(session)).toBeUndefined();
});
});
+33 -1
View File
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
@@ -366,3 +366,35 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
}
}
});
/**
* GitHub Copilot CLI is the same shape as opencode: a full-screen TUI that turns mouse tracking
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
*/
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
expect(isMuxMouseStripMode('copilot', false)).toBe(false);
});
it('drops mouse tracking so xterm keeps local text selection, and keeps 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1003h\x1b[?1006h\x1b[3JTUI\x1b[?1006l\x1b[?1003l');
expect(session.terminalBuffer).toBe('\x1b[3JTUI');
});
it('publishes cliMouseTracking so the browser can forward the wheel and hand-encode clicks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1002h\x1b[?1006hTUI');
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1002l\x1b[?1006l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY copilot pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
+127
View File
@@ -0,0 +1,127 @@
/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */
import { expect, it } from 'vitest';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
agentInspect,
agentStage,
agentSubmit,
registerAgentCommands,
type AgentDeps,
type RequestOptions,
} from '../src/cli-agent.js';
import { Command } from 'commander';
const SELF = '11111111-1111-4111-8111-111111111111';
const OTHER = '22222222-2222-4222-8222-222222222222';
function deps() {
const calls: RequestOptions[] = [],
output: string[] = [];
const deps: AgentDeps = {
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
json: true,
now: () => 1,
io: { out: (line) => output.push(line), err: () => {} },
request: async (_ctx, options) => {
calls.push(options);
return {
status: 200,
text: '',
json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } },
};
},
};
return { deps, calls, output };
}
it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => {
const d = deps();
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0);
expect(d.calls[0]).toMatchObject({
method: 'GET',
path: `/api/v1/sessions/${OTHER}/inspect`,
query: { maxBytes: 42, since: 'cursor' },
});
expect(JSON.parse(d.output[0]).evidence).toBe('hello');
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4);
expect(d.calls).toHaveLength(1);
});
it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => {
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
try {
const path = join(dir, 'message');
await writeFile(path, 'hello');
const d = deps();
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0);
expect(d.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/stage`,
body: { message: 'hello' },
});
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0);
expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) });
await writeFile(path, 'hello\n');
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
await writeFile(path, Buffer.from([0xff]));
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
await writeFile(path, 'x'.repeat(8193));
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
expect(d.calls).toHaveLength(2);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('refuses self-target and malformed submission before transport', async () => {
const d = deps();
expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4);
expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4);
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4);
expect(d.calls).toHaveLength(0);
});
it('registers required file and confirmation options and bounded inspect flags', () => {
const program = new Command();
registerAgentCommands(program);
const commands = program.commands.find((command) => command.name() === 'agent')!.commands;
for (const name of ['inspect', 'stage', 'submit'])
expect(commands.some((command) => command.name() === name)).toBe(true);
expect(
commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file')
?.mandatory
).toBe(true);
expect(
commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha')
?.mandatory
).toBe(true);
});
it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => {
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
try {
const path = join(dir, 'message');
await writeFile(path, 'hello');
const message =
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
for (const json of [false, true]) {
const lines: string[] = [];
const refusing: AgentDeps = {
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
json,
now: () => 1,
io: { out: (line) => lines.push(line), err: (line) => lines.push(line) },
request: async () => ({
status: 403,
text: '',
json: { success: false, error: message, errorCode: 'FORBIDDEN' },
}),
};
expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1);
expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1);
expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1);
expect(lines).toHaveLength(3);
for (const line of lines) {
const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line;
expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`);
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+351
View File
@@ -0,0 +1,351 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import http from 'node:http';
import { tmpdir } from 'node:os';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
agentSpawn,
isHomeDirectory,
registerAgentCommands,
EXIT,
type AgentDeps,
type ApiResponse,
type RequestOptions,
} from '../src/cli-agent.js';
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
const dirs: string[] = [];
afterEach(async () => {
for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true });
});
const ok = (data: unknown): ApiResponse => ({ status: 200, json: { success: true, data }, text: '' });
function deps(responses: ApiResponse[]) {
const calls: RequestOptions[] = [];
const out: string[] = [];
const err: string[] = [];
const d: AgentDeps = {
ctx: { selfId: SELF, apiUrl: 'http://127.0.0.1:1' },
json: true,
now: Date.now,
io: { out: (s) => out.push(s), err: (s) => err.push(s) },
request: async (_ctx, options) => {
calls.push(options);
return responses.shift() ?? ok({});
},
};
return { d, calls, out, err };
}
const base = { mode: 'codex', ready: false, timeoutMs: 1000 };
describe('project-preserving agent spawn', () => {
it('uses existing session create and interactive endpoints with exact directory and lineage', async () => {
const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
dirs.push(directory);
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, { ...base, workingDir: directory, name: 'review' })).toBe(EXIT.ok);
expect(t.calls).toHaveLength(2);
expect(t.calls[0]).toMatchObject({
method: 'POST',
path: '/api/v1/sessions',
body: { workingDir: directory, mode: 'codex', name: 'review', parentSessionId: SELF },
});
expect(t.calls[0].headers ?? {}).not.toHaveProperty('X-Codeman-Agent-Origin');
expect(t.calls[1]).toMatchObject({ method: 'POST', path: `/api/v1/sessions/${OTHER}/interactive` });
expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory });
});
it('defaults to the current directory when no case is supplied', async () => {
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() });
});
it('keeps explicit case quick-start behavior', async () => {
const t = deps([ok({ sessionId: OTHER })]);
expect(await agentSpawn(t.d, { ...base, caseName: 'scratch' })).toBe(EXIT.ok);
expect(t.calls).toHaveLength(1);
expect(t.calls[0].path).toBe('/api/v1/quick-start');
});
it('refuses case and working directory together before network IO', async () => {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, caseName: 'scratch', workingDir: process.cwd() })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
});
it('refuses missing directories, regular files and unknown modes before creating a session', async () => {
const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
dirs.push(directory);
const file = join(directory, 'file');
await writeFile(file, 'x');
for (const options of [{ workingDir: join(directory, 'absent') }, { workingDir: file }, { mode: 'not-a-cli' }]) {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, ...options })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
}
});
it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => {
const t = deps([
ok({ session: { id: OTHER } }),
{ status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.out).toHaveLength(1);
expect(JSON.parse(t.out.join('\n'))).toMatchObject({ sessionId: OTHER, created: true, ready: false });
expect(t.calls).toHaveLength(2);
});
it('does not request startup when creation is refused', async () => {
const t = deps([{ status: 403, text: 'Forbidden' }]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.calls).toHaveLength(1);
});
it('retains the exact created ID on an ambiguous startup transport failure without retrying', async () => {
const t = deps([ok({ session: { id: OTHER } })]);
const request = t.d.request;
t.d.request = async (context, options) => {
if (options.path.endsWith('/interactive')) {
t.calls.push(options);
throw new Error('connection lost');
}
return request(context, options);
};
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.out).toHaveLength(1);
expect(JSON.parse(t.out.join('\n'))).toMatchObject({
sessionId: OTHER,
created: true,
startupError: 'connection lost',
});
expect(t.calls).toHaveLength(2);
});
it('treats an empty startup exception as a failure and keeps the created ID', async () => {
const t = deps([ok({ session: { id: OTHER } })]);
const request = t.d.request;
t.d.request = async (context, options) => {
if (options.path.endsWith('/interactive')) throw new Error('');
return request(context, options);
};
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.out).toHaveLength(1);
expect(JSON.parse(t.out[0])).toMatchObject({
sessionId: OTHER,
ready: false,
startupError: 'Startup request failed',
});
});
it('exposes an optional case and working directory in the CLI', () => {
const spawn = registerAgentCommands(new Command()).commands.find((command) => command.name() === 'spawn')!;
expect(spawn.registeredArguments[0].required).toBe(false);
expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true);
});
it('starts a shell through its registered startMode endpoint', async () => {
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok);
expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`);
});
it('a create answer without session.id is a failure, and nothing is started', async () => {
// `POST /api/sessions` answers `{ session: { id } }`; a bare `{ id }` is not that route.
const t = deps([ok({ id: OTHER })]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.calls).toHaveLength(1);
});
it('an empty case name goes to quick-start (which refuses it), never to the caller cwd', async () => {
const t = deps([
{
status: 400,
text: '',
json: { success: false, error: 'Invalid case name format', errorCode: 'INVALID_INPUT' },
},
]);
expect(await agentSpawn(t.d, { ...base, caseName: '' })).toBe(EXIT.error);
expect(t.calls).toHaveLength(1);
expect(t.calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/quick-start', body: { caseName: '' } });
expect(t.calls[0].body).not.toHaveProperty('workingDir');
});
it('an empty case name with --working-dir is refused like any case plus --working-dir', async () => {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
});
it('an empty or whitespace-only --working-dir is refused before any request, never the caller cwd', async () => {
for (const workingDir of ['', ' ', '\t']) {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, workingDir })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
expect(t.out.join('\n')).toContain('--working-dir is empty');
}
});
it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => {
const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]);
expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok);
expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
'POST /api/v1/sessions',
`POST /api/v1/sessions/${OTHER}/interactive`,
`GET /api/v1/sessions/${OTHER}/wait-output`,
]);
expect(t.calls[2].query).toMatchObject({ match: 'shift+tab', from: 'buffer' });
expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, created: true, ready: true });
});
it('without --json a failed start prints the id alone on stdout, the reason on stderr, and exits 1', async () => {
const t = deps([
ok({ session: { id: OTHER } }),
{ status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
]);
t.d.json = false;
expect(await agentSpawn(t.d, { ...base, ready: true })).toBe(EXIT.error);
expect(t.out).toEqual([OTHER]);
expect(t.err).toHaveLength(1);
expect(t.err[0]).toContain(`Session ${OTHER} was created but startup failed`);
expect(t.err[0]).toContain('cannot start');
expect(t.calls).toHaveLength(2); // no readiness wait after a failed start
});
});
describe('agent spawn through the real argument parser and transport', () => {
async function runSpawn(args: string[]) {
const seen: Array<{ method?: string; url?: string; body: unknown }> = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString();
seen.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined });
res
.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, error: 'refused', errorCode: 'INVALID_INPUT' }));
});
});
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
const { port } = server.address() as { port: number };
const saved = { ...process.env };
const savedExit = process.exitCode;
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
Object.assign(process.env, {
CODEMAN_MUX: '1',
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
CODEMAN_SESSION_ID: SELF,
});
delete process.env.CODEMAN_PASSWORD;
const program = new Command();
program.exitOverride();
registerAgentCommands(program);
await program.parseAsync(['node', 'codeman', 'agent', 'spawn', ...args, '--no-ready', '--json']);
return { seen, exitCode: process.exitCode };
} finally {
process.exitCode = savedExit;
for (const key of ['CODEMAN_MUX', 'CODEMAN_API_URL', 'CODEMAN_SESSION_ID', 'CODEMAN_PASSWORD']) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
log.mockRestore();
error.mockRestore();
await new Promise<void>((done) => server.close(() => done()));
}
}
it('`spawn ""` (as `spawn "$CASE"` with CASE unset) reaches quick-start with caseName "" and exits 1', async () => {
const { seen, exitCode } = await runSpawn(['']);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } });
expect(exitCode).toBe(EXIT.error);
});
it('`spawn --working-dir ""` (as `--working-dir "$WT"` with WT unset) sends nothing and exits 4', async () => {
for (const value of ['', ' ']) {
const { seen, exitCode } = await runSpawn(['--working-dir', value]);
expect(seen).toEqual([]);
expect(exitCode).toBe(EXIT.refused);
}
});
it('`spawn` with no case argument creates in the caller cwd', async () => {
const { seen, exitCode } = await runSpawn([]);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } });
expect(exitCode).toBe(EXIT.error);
});
it('bare `spawn` from a HOME cwd sends nothing and exits 4; `--working-dir <HOME>` is sent', async () => {
const home = await mkdtemp(join(tmpdir(), 'cod549-home-'));
dirs.push(home);
vi.stubEnv('HOME', home);
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(home);
try {
const bare = await runSpawn([]);
expect(bare.seen).toEqual([]);
expect(bare.exitCode).toBe(EXIT.refused);
const explicit = await runSpawn(['--working-dir', home]);
expect(explicit.seen).toHaveLength(1);
expect(explicit.seen[0]).toMatchObject({ url: '/api/v1/sessions', body: { workingDir: home } });
} finally {
cwd.mockRestore();
vi.unstubAllEnvs();
}
});
});
describe('agent spawn never treats an implicit HOME cwd as a project', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
async function homeDir(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'cod549-home-'));
dirs.push(directory);
return directory;
}
it('refuses a cwd that is HOME with exit 4 before any request and names the explicit flag', async () => {
const home = await homeDir();
vi.stubEnv('HOME', home);
vi.spyOn(process, 'cwd').mockReturnValue(home);
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, mode: 'claude' })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
expect(JSON.parse(t.out[0]).error).toContain('pass --working-dir ~');
});
it('compares realpaths, so a symlinked HOME still matches the cwd it points at', async () => {
const real = await homeDir();
const link = join(await homeDir(), 'home-link');
await symlink(real, link);
vi.stubEnv('HOME', link);
vi.spyOn(process, 'cwd').mockReturnValue(real);
const t = deps([]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
});
it('allows an explicit --working-dir that is HOME, as POST /api/sessions does', async () => {
const home = await homeDir();
vi.stubEnv('HOME', home);
vi.spyOn(process, 'cwd').mockReturnValue(home);
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, { ...base, workingDir: home })).toBe(EXIT.ok);
expect(t.calls[0].body).toMatchObject({ workingDir: home });
});
it('allows a cwd below HOME', async () => {
const home = await homeDir();
const project = join(home, 'src', 'app');
await mkdir(project, { recursive: true });
vi.stubEnv('HOME', home);
vi.spyOn(process, 'cwd').mockReturnValue(project);
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
expect(t.calls[0].body).toMatchObject({ workingDir: project });
});
it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => {
const missing = join(await homeDir(), 'absent');
vi.stubEnv('HOME', missing);
await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
await expect(isHomeDirectory(missing)).resolves.toBe(true);
vi.stubEnv('HOME', '');
await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
});
});
describe('the directory-spawn --json shape is documented the same way everywhere', () => {
// The wiki wraps lines, so whitespace is normalized before comparing.
const clause =
'`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed';
for (const file of ['README.md', 'docs/api-reference.md', 'docs/wiki/Driving-Codeman-From-An-Agent.md']) {
it(file, () => {
expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause);
});
}
});
+299
View File
@@ -13,8 +13,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
EXIT,
agentAck,
agentInboxRead,
agentInterrupt,
agentLs,
agentPost,
agentRead,
agentRm,
agentSend,
@@ -27,14 +30,18 @@ import {
buildSendBody,
deleteRefusal,
describeFailure,
describeRequestError,
httpRequest,
inboxCell,
inputRefusal,
isSelfSession,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
postTextFromArgs,
registerAgentCommands,
resolveAgentContext,
stripAnsi,
terminalSafeText,
waitExitCode,
type AgentContext,
type AgentDeps,
@@ -506,6 +513,8 @@ describe('agent spawn', () => {
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
expect(composerReadyMark('claude')).toBe('shift+tab');
expect(composerReadyMark('deepseek')).toBe('❯');
// Copilot's trust dialog draws `❯ 1. Yes`, so its mark is the composer's footer hint instead.
expect(composerReadyMark('copilot')).toBe('/ commands · ? help');
expect(composerReadyMark('pi')).toBeUndefined();
expect(composerReadyMark('no-such-cli')).toBeUndefined();
});
@@ -540,6 +549,75 @@ describe('agent spawn', () => {
expect(deps.calls).toHaveLength(1);
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
});
it.each([false, true])('preserves the created id when readiness throws (json=%s)', async (json) => {
const deps = fakeDeps((options) => {
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER, caseName: 'c' });
throw new Error('readiness connection reset');
}, json);
const exit = await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 }).catch(
() => undefined
);
if (json) {
expect(deps.out).toHaveLength(1);
expect(JSON.parse(deps.out[0])).toEqual({
sessionId: OTHER,
caseName: 'c',
readinessError: 'readiness connection reset',
});
expect(deps.err).toEqual([]);
} else {
expect(deps.out).toEqual([OTHER]);
expect(deps.err.join('')).toContain('readiness check failed: readiness connection reset');
expect(deps.err.join('')).not.toMatch(/composer not seen|startup dialog/);
}
expect(exit).toBe(EXIT.error);
expect(deps.calls.map(({ method, path }) => ({ method, path }))).toEqual([
{ method: 'POST', path: '/api/v1/quick-start' },
{ method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` },
]);
});
it.each([
{ error: new Error(''), kind: 'Error' },
{ error: '', kind: 'string' },
])('reports a readiness failure when the thrown $kind has no message', async ({ error }) => {
const deps = fakeDeps((options) => {
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
throw error;
}, true);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.out).toHaveLength(1);
expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'request failed without a message' });
expect(deps.calls).toHaveLength(2);
});
it('keeps the error code when a refused connection rejects with an empty AggregateError', async () => {
// Node 22 on a host resolving to ::1 and 127.0.0.1: message '', code ECONNREFUSED.
const refused = Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:3000')], ''), {
code: 'ECONNREFUSED',
});
const deps = fakeDeps((options) => {
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
throw refused;
}, true);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'ECONNREFUSED' });
});
});
describe('describeRequestError', () => {
it('prefers the message, then the code, then a generic line', () => {
expect(describeRequestError(new Error('socket hang up'))).toBe('socket hang up');
expect(describeRequestError(Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' }))).toBe(
'ECONNREFUSED'
);
expect(describeRequestError(new Error(''))).toBe('request failed without a message');
expect(describeRequestError('')).toBe('request failed without a message');
});
});
describe('session id prefixes', () => {
@@ -605,6 +683,196 @@ describe('session id prefixes', () => {
});
});
describe('agent inbox verbs', () => {
const MSG = {
id: 'm-1',
from: OTHER,
text: 'line one\nline two',
createdAt: 1_700_000_000_000 - 90_000,
seen: false,
};
it('ls merges the inbox summary into an INBOX column', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({
sessions: {
[SELF]: { pending: 2, unseen: 1, oldestUnseenAt: 1, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
},
})
: ok([
{ id: SELF, mode: 'claude', status: 'busy', name: 'me' },
{ id: OTHER, mode: 'codex', status: 'idle', name: 'worker' },
])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
const text = deps.out.join('\n');
expect(text).toMatch(/ID\s+MODE\s+STATUS\s+INBOX\s+NAME/);
expect(text).toMatch(/058ee7b5\s+claude\s+busy\s+2 \(1 new\)\s+me/);
expect(text).toMatch(/94990c6d\s+codex\s+idle\s+parked\s+worker/);
});
it('ls --json carries each session’s inbox row (null when it has none)', async () => {
const deps = fakeDeps(
(o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({ sessions: { [OTHER]: { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false } } })
: ok([{ id: SELF }, { id: OTHER }]),
true
);
await agentLs(deps);
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; inbox: unknown }>;
expect(parsed.map((s) => s.inbox)).toEqual([null, { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false }]);
});
it('ls still lists sessions when the server has no summary route', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary' ? apiError(404, 'NOT_FOUND', 'Route not found') : ok([{ id: OTHER }])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
expect(deps.out.join('\n')).not.toContain('INBOX');
});
it('inboxCell', () => {
expect(inboxCell(undefined)).toBe('');
expect(inboxCell({ pending: 3, unseen: 0, oldestUnseenAt: null, parked: false })).toBe('3');
expect(inboxCell({ pending: 3, unseen: 2, oldestUnseenAt: 1, parked: true })).toBe('3 (2 new), parked');
});
it('post sends the text as data to the resolved session and never to /input', async () => {
const deps = fakeDeps([ok({ message: MSG, pending: 1, unseen: 1 })]);
expect(await agentPost(deps, { id: OTHER, text: 'line one\nline two' })).toBe(EXIT.ok);
expect(deps.calls).toEqual([
expect.objectContaining({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/inbox`,
body: { text: 'line one\nline two' },
}),
]);
expect(deps.out.join('')).toMatch(/posted to 94990c6d \(1 pending, 1 unseen\)/);
});
it('post passes a full inbox (409) through as an error', async () => {
const deps = fakeDeps([apiError(409, 'CONFLICT', 'Inbox of x is full (200 messages).')]);
expect(await agentPost(deps, { id: OTHER, text: 'x' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/CONFLICT: Inbox of x is full/);
});
it('post takes ONE argument and refuses an empty one before any request', async () => {
expect(postTextFromArgs(['a\nb'])).toEqual({ text: 'a\nb' });
expect(postTextFromArgs(['a', 'b'])).toEqual({ error: expect.stringMatching(/ONE argument, got 2/) });
const deps = fakeDeps([]);
expect(await agentPost(deps, { id: OTHER, text: '' })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('inbox reads this session’s own inbox by default and prints full ids plus the ack hint', async () => {
const deps = fakeDeps([ok({ receiver: true, messages: [MSG], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ method: 'GET', path: `/api/v1/sessions/${SELF}/inbox` });
expect(deps.calls[0].query).toEqual({ wait: undefined });
const text = deps.out.join('\n');
expect(text).toMatch(/── m-1 from 94990c6d, 2m ago, new/);
expect(text).toContain('line one\nline two');
expect(deps.err.join('')).toMatch(/codeman agent ack <id>/);
});
it('inbox prints message text with escape sequences and control characters neutralized', async () => {
const hostile = {
...MSG,
text: 'title \x1b]0;pwned\x07ok\r\nred \x1b[31mtext\x1b[0m\rover\bwrite\ttab\x9b2J\nend',
};
const deps = fakeDeps([ok({ receiver: true, messages: [hostile], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
// out[0] is the `── <id> from …` header (styled by the palette), out[1] the text.
expect(deps.out).toHaveLength(2);
expect(deps.out[1]).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/);
expect(deps.out[1]).toBe('title ok\nred text\\x0dover\\x08write\ttab\\x9b2J\nend');
});
it('terminalSafeText keeps newline and tab and escapes every other control', () => {
expect(terminalSafeText('a\nb\tc')).toBe('a\nb\tc');
expect(terminalSafeText('a\r\nb')).toBe('a\nb');
expect(terminalSafeText('\x1b[2J\x1bc\x00\x7f')).toBe('\\x1bc\\x00\\x7f');
expect(terminalSafeText('ünïcödé ✓')).toBe('ünïcödé ✓');
});
it('inbox --json hands the text out as stored', async () => {
const raw = { ...MSG, text: 'red \x1b[31mtext' };
const deps = fakeDeps([ok({ receiver: true, messages: [raw], pending: 1, unseen: 0 })], true);
await agentInboxRead(deps, {});
expect((JSON.parse(deps.out.join('')) as { messages: Array<{ text: string }> }).messages[0].text).toBe(
'red \x1b[31mtext'
);
});
it('inbox <id> is a peek and says so', async () => {
const deps = fakeDeps([ok([{ id: OTHER }]), ok({ receiver: false, messages: [MSG], pending: 1, unseen: 1 })]);
expect(await agentInboxRead(deps, { id: OTHER.slice(0, 8) })).toBe(EXIT.ok);
expect(deps.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/inbox`);
expect(deps.err.join('')).toMatch(/peek: nothing was marked seen/);
});
it('inbox --wait passes the budget and maps timeout to exit 2', async () => {
const timedOut = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { timedOut: true } })]);
expect(await agentInboxRead(timedOut, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(timedOut.calls[0]).toMatchObject({ query: { wait: 30_000 }, timeoutMs: 40_000 });
});
describe('a wait the server ended', () => {
const ended = ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { ended: true } });
it('is exit 3 only when the session list confirms the session is gone', async () => {
const deps = fakeDeps([ended, ok([{ id: OTHER }])]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.dead);
expect(deps.calls[1]).toMatchObject({ method: 'GET', path: '/api/v1/sessions' });
expect(deps.err.join('')).toMatch(/went away/);
});
it('is exit 2 (wait again) while the session is still listed: a restart or detach', async () => {
const deps = fakeDeps([ended, ok([{ id: SELF }])]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(deps.err.join('')).toMatch(/ended the wait early/);
});
it('is exit 2 when the list cannot be had (the server is still coming back)', async () => {
const deps = fakeDeps([ended, apiError(503, 'INTERNAL_ERROR', 'restarting')]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
});
});
it('ack posts the ids to this session’s own inbox', async () => {
const deps = fakeDeps([ok({ removed: 2, pending: 0 })]);
expect(await agentAck(deps, { ids: ['m-1', ' m-2 '] })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${SELF}/inbox/ack`,
body: { ids: ['m-1', 'm-2'] },
});
expect(deps.out.join('')).toMatch(/acked 2 \(0 still pending\)/);
});
it('ack refuses without ids and passes a 403 through', async () => {
const none = fakeDeps([]);
expect(await agentAck(none, { ids: [' '] })).toBe(EXIT.refused);
expect(none.calls).toEqual([]);
const forbidden = fakeDeps([apiError(403, 'FORBIDDEN', 'Only x itself can acknowledge its inbox')]);
expect(await agentAck(forbidden, { ids: ['m-1'] })).toBe(EXIT.error);
});
it('none of the inbox verbs writes to a pane', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/sessions' ? ok([{ id: OTHER }]) : ok({ messages: [], pending: 0, unseen: 0 })
);
await agentPost(deps, { id: OTHER, text: 'x' });
await agentInboxRead(deps, {});
await agentInboxRead(deps, { id: OTHER, waitMs: 1000 });
await agentAck(deps, { ids: ['m'] });
for (const call of deps.calls) expect(call.path).not.toMatch(/\/input$/);
});
});
describe('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
@@ -619,6 +887,26 @@ describe('help texts carry the traps the skill documents', () => {
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
});
it('post takes text that starts with "-" (markdown from a file) without --', async () => {
const program = new Command();
program.exitOverride();
const post = registerAgentCommands(program).commands.find((c) => c.name() === 'post')!;
const got: Array<{ words: string[]; json: boolean }> = [];
post.action((_id: string, words: string[], o: { json?: boolean }) => {
got.push({ words, json: Boolean(o.json) });
});
for (const text of ['- [ ] item\nnext', '---\ntitle: a', '-v is great']) {
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', text]);
}
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', 'hi', '--json']);
expect(got).toEqual([
{ words: ['- [ ] item\nnext'], json: false },
{ words: ['---\ntitle: a'], json: false },
{ words: ['-v is great'], json: false },
{ words: ['hi'], json: true },
]);
});
it('rm does not claim a lineage check it does not make', () => {
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
});
@@ -660,6 +948,13 @@ describe('httpRequest', () => {
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
return;
}
if (req.url?.startsWith('/reset')) {
// Headers and half a body, then the connection drops (a restart mid-answer).
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write('{"success":true,"data":');
setTimeout(() => req.socket.destroy(), 50);
return;
}
res
.writeHead(200, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: true, data: { echo: true } }));
@@ -702,6 +997,10 @@ describe('httpRequest', () => {
expect(last.headers.authorization).toBeUndefined();
});
it('rejects, rather than hanging, when the connection drops mid-body', async () => {
await expect(httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/reset', timeoutMs: 5000 })).rejects.toThrow();
});
it('keeps a plain-text body when the answer is not JSON', async () => {
const res = await httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/plain' });
expect(res.status).toBe(401);
+2 -1
View File
@@ -36,6 +36,7 @@ describe('per-mode capability predicates', () => {
['grok', true, false, false],
['deepseek', true, false, true],
['omp', true, false, false],
['copilot', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
@@ -48,7 +49,7 @@ describe('per-mode capability predicates', () => {
it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality.
expect(MODES.length).toBe(10);
expect(MODES.length).toBe(11);
});
it('keeps the three predicates genuinely distinct', () => {
+26
View File
@@ -208,3 +208,29 @@ describe('help text', () => {
}
});
});
describe('unauthenticated tunnel launch option', () => {
it.each(['web', 'service install'])('registers --allow-unauthenticated-tunnel on %s', (path) => {
const command = path === 'web' ? find(program, 'web')! : find(find(program, 'service')!, 'install')!;
const options = command.options.filter((option) => option.long === '--allow-unauthenticated-tunnel');
expect(
options.map((option) => ({
flags: option.flags,
attribute: option.attributeName(),
required: option.required,
optional: option.optional,
}))
).toEqual([
{
flags: '--allow-unauthenticated-tunnel',
attribute: 'allowUnauthenticatedTunnel',
required: false,
optional: false,
},
]);
});
it('documents --allow-unauthenticated-tunnel in web help', () => {
expect(find(program, 'web')!.helpInformation()).toContain('--allow-unauthenticated-tunnel');
});
});
+141
View File
@@ -0,0 +1,141 @@
// @vitest-environment node
/**
* @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a
* recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1.
*/
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js';
const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' };
function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) {
const post = vi.fn<JsonPost>(async () => ({ status, error, delivery }));
return post;
}
describe('runNotify', () => {
it('refuses outside a Codeman session, before any request', async () => {
const post = fake(200);
const r = await runNotify({ message: 'hi' }, {}, '/work', post);
expect(r.code).toBe(1);
expect(r.err).toMatch(/inside a Codeman session/);
expect(post).not.toHaveBeenCalled();
});
it('posts the message to this session with the session id URL-encoded', async () => {
const post = fake(200);
const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post);
expect(r).toEqual({ code: 0, out: 'Posted to Discord' });
expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', {
message: 'press the reset button',
});
});
it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => {
const post = fake(200);
await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post);
expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' });
await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post);
expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' });
});
it('lets --session and --url override the environment', async () => {
const post = fake(200);
await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post);
expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']);
});
it('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => {
const r = await runNotify(
{ message: 'x' },
ENV,
'/w',
fake(200, undefined, { ok: false, error: 'The bot token was refused' })
);
expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' });
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0);
});
it('postJson gives up on a server that never answers, with status 0', async () => {
const server = createServer(() => undefined);
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
try {
const { port } = server.address() as AddressInfo;
const r = await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100);
expect(r.status).toBe(0);
} finally {
server.closeAllConnections();
server.close();
}
});
it('postJson settles when the server sends headers and then stalls the body', async () => {
const server = createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' });
res.write('{"success":');
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
try {
const { port } = server.address() as AddressInfo;
expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0);
} finally {
server.closeAllConnections();
server.close();
}
});
it('treats a disabled bridge (409) as success that says so', async () => {
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
expect(r.code).toBe(0);
expect(r.out).toMatch(/bridge is off/);
});
it('fails with the server error, or the status, or a plain message when nothing answered', async () => {
expect(
(await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err
).toBe('Could not post to Discord: The file must be inside the session folder');
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(429))).err).toMatch(/Too many messages/);
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)');
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord');
});
});
describe('postJson (real transport)', () => {
let server: Server;
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
async function serve(status: number, reply: unknown) {
const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' };
server = createServer((req, res) => {
req.on('data', (c) => (seen.body += c));
req.on('end', () => {
seen.method = req.method;
seen.url = req.url;
seen.type = req.headers['content-type'];
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(reply));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
}
it('sends a JSON POST and reports the status and the server error text', async () => {
const { seen, url } = await serve(400, { success: false, error: 'nope' });
expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({
status: 400,
error: 'nope',
});
expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' });
expect(JSON.parse(seen.body)).toEqual({ message: 'hi' });
});
it('answers status 0 when nothing is listening', async () => {
const { url } = await serve(200, {});
await new Promise<void>((resolve) => server.close(() => resolve()));
expect((await postJson(url, '/x', {})).status).toBe(0);
server = createServer().listen(0); // so afterEach has something to close
});
});
+2 -12
View File
@@ -95,6 +95,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"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 === 'copilot'": 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)'),
@@ -102,6 +103,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"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 === 'copilot'": 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)'),
@@ -124,10 +126,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
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(
@@ -169,14 +167,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
),
"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'": 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'": allow(
+245
View File
@@ -55,6 +55,65 @@ describe('customModelInjection.launchModel', () => {
});
});
/** Mutates the shipped deepseek configDir recipe (the one real-home-linking entry) and expects rejection. */
function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => void, because: string): void {
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
mutate(recipe);
const result = CliEntrySchema.safeParse(entry);
expect(result.success, `expected rejection: ${because}`).toBe(false);
}
describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => {
it('rejects a realHomeFallback that is not a single relative path segment', () => {
// The consumer joins it under the user's home, so any of these escapes that home:
// and bare `.` IS that home: linking through it would expose the whole tree
// unshielded instead of just the declared segments.
for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) {
expectRejectedOnDeepSeek((r) => {
r.realHomeFallback = bad;
}, `realHomeFallback ${JSON.stringify(bad)}`);
}
});
it('accepts a shipped-style dot-prefixed segment', () => {
const entry = baseEntry('deepseek');
expect(CliEntrySchema.safeParse(entry).success).toBe(true); // ships as `.dsh`
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
recipe.realHomeFallback = '.dsh-relocated';
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
it('requires realHomeFallback whenever linkFromRealHome is declared', () => {
expectRejectedOnDeepSeek((r) => {
delete r.realHomeFallback;
}, 'without it the consumer would have to hardcode a per-CLI home default');
});
it('rejects a linked entry that collides with fileName', () => {
// A symlink at the generated file's own name would write THROUGH into the user's real config.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['cordis.patch.yml', 'profiles'];
}, 'linkFromRealHome containing fileName');
});
it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => {
// types.ts documents the rule; a name absent from the linked list names nothing,
// so the create-missing-directory behaviour would silently never apply to it.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['profiles', 'settings.yaml'];
r.linkFromRealHomeDirs = ['profiles', 'sessions'];
}, 'linkFromRealHomeDirs naming an unlinked entry');
// The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs
// of the three linked entries) and keeps loading.
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']);
expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']);
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
describe('the shipped catalog', () => {
it('validates every stock entry exactly as shipped', () => {
// If this fails, the catalog cannot load at all — every other test here is downstream.
@@ -183,6 +242,126 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional composerFooterLine to the same guard', () => {
// Run over the rows under the composer each time guarded input reads the pane.
for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerFooterLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
});
it('accepts every shipped composerFooterLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerFooterLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => {
// Run over the rows just above the composer each time guarded input reads the pane.
for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerPopupLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerPopupLine;
if (src)
expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull();
}
});
it('takes only short runs of ASCII punctuation as composer trigger characters', () => {
for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) {
for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: value,
};
},
`${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set`
);
}
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: '/!@$',
};
expect(CliEntrySchema.safeParse(entry).success, field).toBe(true);
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
for (const blockedLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine,
};
},
`blockedLine ${JSON.stringify(blockedLine.slice(0, 20))} must be refused at LOAD time`
);
}
});
it('bounds the blockedLine window and refuses one with no pattern', () => {
for (const blockedLines of [0, 25, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLines: 4,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts a blockedLine with its window, and every shipped one', () => {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines: 24,
};
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
let shipped = 0;
for (const stock of STOCK_CLIS) {
const src = stock.capabilities.workDetect?.blockedLine;
if (!src) continue;
shipped++;
expect(compileVersionRegex(src), `${stock.id} declares a blockedLine the guard refuses`).not.toBeNull();
expect(CliEntrySchema.safeParse(JSON.parse(JSON.stringify(stock))).success).toBe(true);
}
expect(shipped).toBeGreaterThan(0);
});
});
describe('no shell text can reach the command line', () => {
@@ -414,4 +593,70 @@ describe('capability shapes', () => {
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
}, 'a transcript reader that does not exist would silently read nothing');
});
it('takes transcriptPageKeys as an optional boolean, absent meaning no paging', () => {
for (const value of [true, false]) {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).transcriptPageKeys = value;
expect(CliEntrySchema.safeParse(entry).success, `transcriptPageKeys=${value}`).toBe(true);
}
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).transcriptPageKeys;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).transcriptPageKeys = 'yes';
}, 'transcriptPageKeys is a boolean');
});
it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => {
const inline = baseEntry();
(inline.capabilities as Record<string, unknown>).keyboardHold = 'inline';
expect(CliEntrySchema.safeParse(inline).success).toBe(true);
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).keyboardHold;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
for (const value of [true, 'fullscreen', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).keyboardHold = value;
},
`keyboardHold=${JSON.stringify(value)} names no renderer kind`
);
}
});
it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => {
// Claude Code 2.1.306's inline renderer was measured dropping transcript lines from
// tmux on keyboard-driven height changes; every other stock entry resizes.
const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string);
expect(holding).toEqual(['claude']);
});
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
});
});
describe('observed mouse-tracking wheel policy', () => {
it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => {
const codex = STOCK_CLIS.find((e) => e.id === 'codex')!;
expect(codex.capabilities.wheelForward).toEqual({ mode: 'always' });
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
expect(codex.capabilities.transcriptPageKeys).toBe(true);
});
});
+102
View File
@@ -102,6 +102,30 @@ describe('claude', () => {
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
it('folds the keep-warm idleCompaction opt-out LAST into the one --settings JSON, and only when asked', () => {
const launch = {
claudeCliVersion: '2.1.300',
statusLineCommand: '/x/exporter.sh',
advisorModel: 'opus',
sessionName: 'w1 alpha',
};
// Keep-warm off: byte-identical to the launch from before keep-warm existed.
expect(claude(launch)).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"}}\' --name "w1 alpha"'
);
expect(claude({ ...launch, effort: 'ultracode', idleCompactionOff: true })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"ultracode":true,"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"},"idleCompaction":false}\' --name "w1 alpha"'
);
// The builder folds the key whenever asked, even with nothing else in the JSON (the
// launch only asks alongside the exporter, but the builder does not rely on that).
expect(claude({ idleCompactionOff: true })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"idleCompaction":false}\''
);
});
});
describe('opencode', () => {
@@ -147,6 +171,15 @@ describe('codex', () => {
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
});
it('carries an Ollama-style `qwen3:32b` model id, and still drops a leading-flag shape', () => {
// `model-plain-colon`: the custom-model feature forces the selected id onto this param,
// and ids like `qwen3:32b` carry a colon. The leading-alphanumeric rule is what keeps a
// flag from riding the flag, so `:x` and `-x` must still drop out.
expect(cx({ model: 'qwen3:32b' })).toBe('codex --model qwen3:32b');
expect(cx({ model: ':leading' })).toBe('codex');
expect(cx({ model: '-yolo' })).toBe('codex');
});
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
for (const level of CODEX_REASONING_EFFORTS) {
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
@@ -320,3 +353,72 @@ describe('unsafe values are DROPPED, never escaped into the command', () => {
expect(cmd).not.toContain('..');
});
});
describe('copilot', () => {
const cp = (copilotConfig?: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, copilotConfig });
it("spawns bare by default, in Copilot's own Manual Approval mode", () => {
expect(cp()).toBe('copilot');
});
it('emits --yolo only when asked, with the model', () => {
expect(cp({ allowAll: true, model: 'claude-sonnet-5.5' })).toBe('copilot --yolo --model claude-sonnet-5.5');
expect(cp({ allowAll: false })).toBe('copilot');
});
it("pins the tab's name as --name", () => {
expect(render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman' } as never)).toBe(
'copilot --name "w4-Codeman"'
);
});
it('drops --name next to --resume and --continue, which copilot refuses to combine', () => {
const named = (copilotConfig: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman', copilotConfig } as never);
expect(named({ resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
expect(named({ continueSession: true })).toBe('copilot --continue');
expect(named({ allowAll: true, model: 'auto' })).toBe('copilot --yolo --name "w4-Codeman" --model auto');
});
it('prefers an explicit resume id over --continue', () => {
expect(cp({ resumeSessionId: '0c02c30f-3383-4139-8b52-b28abad2ad43' })).toBe(
'copilot --resume 0c02c30f-3383-4139-8b52-b28abad2ad43'
);
expect(cp({ continueSession: true })).toBe('copilot --continue');
expect(cp({ continueSession: true, resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
});
it('drops unsafe values rather than escaping them', () => {
expect(cp({ model: 'a`b' })).toBe('copilot');
expect(cp({ resumeSessionId: 'my session; rm -rf /' })).toBe('copilot');
});
// `--resume`'s value is optional, so `--resume --yolo` would make `--yolo` its own flag and get
// around the allowAll clamp. A resume value must start with an alphanumeric.
it('drops a resume value that would be read as a flag', () => {
for (const resumeSessionId of ['--yolo', '-n', '--allow-all-tools', '.', '..', '.hidden']) {
expect(cp({ resumeSessionId })).toBe('copilot');
}
expect(cp({ allowAll: false, resumeSessionId: '--yolo' })).toBe('copilot');
// A plain word is still passed through (a session NAME also resumes).
expect(cp({ resumeSessionId: 'my-feature' })).toBe('copilot --resume my-feature');
});
// `--model --yolo` would make `--yolo` its own flag and get around the allowAll clamp, so a model id
// starts with an alphanumeric too. A provider/id value (the `/`) is still accepted.
it('drops a model value that would be read as a flag', () => {
for (const model of ['--yolo', '-n', '--allow-all-tools', '.', '/etc/passwd']) {
expect(cp({ model })).toBe('copilot');
expect(cp({ allowAll: false, model })).toBe('copilot');
}
expect(cp({ model: 'claude-sonnet-5.5' })).toBe('copilot --model claude-sonnet-5.5');
expect(cp({ model: 'openai/gpt-5' })).toBe('copilot --model openai/gpt-5');
});
it('never puts a credential on the command line', () => {
const cmd = cp({ allowAll: true, model: 'auto' }) ?? '';
expect(cmd).not.toContain('token');
expect(cmd).not.toContain('key');
});
});
+7 -5
View File
@@ -132,20 +132,22 @@ describe('linked-cases.json tolerance', () => {
});
describe('skill command wiring', () => {
it('registers install and uninstall, both accepting --case and --global', () => {
it('registers install and uninstall, both accepting --case and --global, install also --mode', () => {
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
expect(skill).toBeDefined();
const subcommands = skill!.commands.map((cmd) => cmd.name());
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
for (const name of ['install', 'uninstall']) {
const flags = skill!.commands
const flagsOf = (name: string) =>
skill!.commands
.find((cmd) => cmd.name() === name)!
.options.map((opt) => opt.long)
.sort();
expect(flags).toEqual(['--case', '--global']);
}
// --mode picks the skill and directory that run mode reads (capabilities.agentSkill);
// uninstall needs none, since --case removes every Codeman-managed copy.
expect(flagsOf('install')).toEqual(['--case', '--global', '--mode']);
expect(flagsOf('uninstall')).toEqual(['--case', '--global']);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Issue #706 tunnel flag plumbing through the real command parser into daemon, foreground and service launches. Launch boundaries are mocked, so the CLI's valid nonzero port never binds a socket or creates a service unit.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const launches = vi.hoisted(() => ({
startDaemon: vi.fn(),
startWebServer: vi.fn(),
installService: vi.fn(),
stop: vi.fn(),
}));
vi.mock('../src/daemon-control.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/daemon-control.js')>()),
startDaemon: launches.startDaemon,
}));
vi.mock('../src/service-installer.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/service-installer.js')>()),
installService: launches.installService,
}));
vi.mock('../src/web/server.js', () => ({ startWebServer: launches.startWebServer }));
const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const;
let originalListeners: Map<NodeJS.Signals, ReturnType<typeof process.listeners>>;
let exitSpy: ReturnType<typeof vi.spyOn<typeof process, 'exit'>>;
beforeEach(() => {
// Commander retains parsed option values; use a fresh real program for each invocation.
vi.resetModules();
vi.clearAllMocks();
originalListeners = new Map(SIGNALS.map((signal) => [signal, process.listeners(signal)]));
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`Unexpected process.exit(${code})`);
});
launches.startDaemon.mockResolvedValue({ ok: true, url: 'http://127.0.0.1:8080', pid: 123, logPath: 'web.log' });
launches.stop.mockResolvedValue(undefined);
launches.startWebServer.mockResolvedValue({ stop: launches.stop });
launches.installService.mockResolvedValue({ ok: true, message: 'Installed', unitPath: 'codeman-web.service' });
});
afterEach(() => {
for (const signal of SIGNALS) {
for (const listener of process.listeners(signal)) {
if (!originalListeners.get(signal)!.includes(listener)) process.removeListener(signal, listener);
}
}
vi.restoreAllMocks();
});
const expectedOptions = (allowUnauthenticatedTunnel: boolean) => ({
host: '127.0.0.1',
port: 8080,
https: false,
basePath: '',
titleHostname: undefined,
allowUnauthenticatedNetwork: false,
allowUnauthenticatedTunnel,
multiuser: false,
});
async function parse(args: string[], enabled: boolean) {
const { program } = await import('../src/cli.js');
await program.parseAsync([
'node',
'codeman',
...args,
'--port',
'8080',
'--host',
'127.0.0.1',
'--base-url',
'/',
...(enabled ? ['--allow-unauthenticated-tunnel'] : []),
]);
expect(exitSpy).not.toHaveBeenCalled();
}
describe('tunnel flag CLI plumbing', () => {
it.each([true, false])('web -d passes allowUnauthenticatedTunnel=%s to startDaemon', async (enabled) => {
await parse(['web', '-d'], enabled);
expect(launches.startDaemon.mock.calls).toEqual([[expectedOptions(enabled)]]);
expect(launches.startWebServer.mock.calls).toEqual([]);
expect(launches.installService.mock.calls).toEqual([]);
});
it.each([true, false])('foreground web passes allowUnauthenticatedTunnel=%s as argument eight', async (enabled) => {
await parse(['web'], enabled);
expect(launches.startWebServer.mock.calls).toEqual([
[8080, false, false, '127.0.0.1', undefined, false, '', enabled],
]);
expect(launches.startDaemon.mock.calls).toEqual([]);
expect(launches.installService.mock.calls).toEqual([]);
expect(launches.stop.mock.calls).toEqual([]);
});
it.each([true, false])('service install passes allowUnauthenticatedTunnel=%s to installService', async (enabled) => {
await parse(['service', 'install'], enabled);
expect(launches.installService.mock.calls).toEqual([[expectedOptions(enabled)]]);
expect(launches.startDaemon.mock.calls).toEqual([]);
expect(launches.startWebServer.mock.calls).toEqual([]);
});
});
+421
View File
@@ -0,0 +1,421 @@
/**
* A Codex session waiting on one of its own dialogs is BLOCKED, read off the screen.
*
* Codex is the one CLI Codeman launches with its approvals on by default, and it has no
* hook to say it stopped for one (`hooks: 'none'`), so a session parked on "Would you like
* to run the following command?" used to read as plainly idle, and one parked on a
* question (whose key hints carry `esc to interrupt`, codex's working line) as WORKING.
* The registry's `capabilities.workDetect.blockedLine` now names the dialog's chrome, the
* session reads it at every settle and on a timer while it is up, and the server raises
* exactly what a Claude `permission_prompt` hook raises, alert-only.
*
* The fixtures in `test/fixtures/codex-dialogs/` are live `tmux capture-pane -p` frames
* (no -J, what `capturePaneText()` reads) of codex-cli 0.162.0 on 2026-10-14, run with
* `-a on-request -s read-only` at 200, 80, 60, 44 and 36 columns; only trailing
* whitespace was trimmed and the scratch path replaced by `/tmp/codex-scratch/repo`.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import {
isBlockedScreen,
BLOCKED_TAIL_LINES,
BLOCKED_MAX_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_CLEAR_READS,
BLOCKED_QUIET_RECHECK_MS,
IDLE_SILENCE_MS,
PANE_PROBE_MIN_INTERVAL_MS,
} from '../src/session-activity.js';
import type { SessionMode } from '../src/types.js';
const DETECT = getCli('codex')!.capabilities.workDetect!;
const CODEX_BLOCKED = compileVersionRegex(DETECT.blockedLine!)!;
const CODEX_WINDOW = DETECT.blockedLines!;
const CODEX_WORKING = compileVersionRegex(DETECT.workingLine)!;
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
const blocked = (screen: string): boolean => isBlockedScreen(screen, CODEX_BLOCKED, CODEX_WINDOW);
/** Every dialog codex was measured blocking on. */
const DIALOGS = [
'exec-approval.txt',
'exec-approval-w36.txt', // the key hint wraps: `Press enter to confirm or esc to` / `cancel`
'exec-approval-w44.txt',
'exec-approval-long-command.txt', // a 14-row heredoc preview between the header and the options
'exec-approval-60x14.txt', // a 14-row pane: codex folds the preview to `[… 21 lines]`
'edit-approval.txt',
'network-escalation-approval.txt',
'question.txt',
'question-option2-selected.txt',
'question-1of2.txt', // `| ←/→ to navigate questions |` in the hint
'question-2of2-submit-all.txt', // `enter to submit all` on the last question
'question-w36.txt', // the hint one segment per row, the `›` row 16 rows up
'question-w60.txt',
'question-notes.txt', // the notes field open: `tab or esc to clear notes | enter to submit answer`
'question-notes-typed.txt',
'trust-dialog.txt', // drawn at the TOP of an otherwise blank screen at startup
'trust-dialog-w40.txt',
// The same prompt, reached through the update prompt (`2. Skip`) on 0.162.0 with 0.163.0
// out: `2. Back to Agent Command Center` over `enter continue · esc back`. Captured
// 2026-10-16 from Codeman's own launch flags in a fresh git repo, at 120 columns.
'trust-dialog-back.txt',
];
/** Screens with no dialog codex blocks on, several of them quoting the dialogs' words. */
const NOT_DIALOGS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt', // the transcript keeps `• Questions 1/1 answered` and the question
'slash-popup.txt', // `› /plan switch to Plan mode`: a `›` row with nothing after it
'transcript-quotes-dialog.txt', // an answer that printed the dialog's lines verbatim
'composer-typed-dialog-text.txt', // the user TYPED them into the composer
'composer-typed-dialog-text-w60.txt', // ... and at 60 columns, where the text wraps
'model-popup.txt', // menus the user opened at the keyboard: `enter select · esc back`
'permissions-popup.txt', // (with the quoted dialog from the earlier answer still above it)
'review-popup.txt',
'implement-plan.txt', // deliberately not blocked: a plan turn that ended, same footer as /model
];
describe('codex blockedLine on live captures', () => {
it('is a pattern the registry guard accepts, with a window inside the cap', () => {
expect(DETECT.blockedLine!.length).toBeLessThanOrEqual(200);
expect(CODEX_BLOCKED).not.toBeNull();
expect(CODEX_WINDOW).toBeGreaterThan(BLOCKED_TAIL_LINES);
expect(CODEX_WINDOW).toBeLessThanOrEqual(BLOCKED_MAX_TAIL_LINES);
});
it.each(DIALOGS)('reads %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(true);
});
it.each(NOT_DIALOGS)('does not read %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(false);
});
it('needs the whole window for the narrowest question, and no more than it declares', () => {
// At 36 columns the selected option sits 16 non-blank rows above the end; a window
// that stops short of it reads nothing, which is the pre-field behaviour, never a
// false alert.
const narrow = fixture('question-w36.txt');
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 16)).toBe(true);
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 15)).toBe(false);
});
it('sees the question dialogs that the working line used to claim', () => {
// Why a blocked screen has to WIN over the working line in the probe: the question's
// own key hints end in `esc to interrupt`.
for (const name of ['question.txt', 'question-w36.txt', 'question-1of2.txt']) {
expect(CODEX_WORKING.test(fixture(name)), name).toBe(true);
expect(blocked(fixture(name)), name).toBe(true);
}
});
});
describe('codex blockedLine against a screen the agent writes', () => {
// The foot of codex's screen while no dialog is up: composer, footer (whose model and
// path vary), hint row.
const composer = (typed = 'Ask Codex to do anything', footer = ' GPT-6-Luna default · ~/cases/x'): string[] => [
`› ${typed}`,
'',
footer,
' ? for shortcuts',
];
const forgedDialog = [
'• Here is what the dialog looks like:',
'› 1. Yes, proceed (y)',
' 2. No, and tell Codex what to do differently (esc)',
'',
' Press enter to confirm or esc to cancel',
];
it('refuses a forged dialog in the transcript, whatever follows it', () => {
expect(blocked([...forgedDialog, '', ...composer()].join('\n'))).toBe(false);
// While a prompt is typed the hint row goes away, so the forged hint sits only two
// rows above the end: the composer row (column 0, `›`) still breaks the run.
expect(blocked([...forgedDialog, ...composer('next thing').slice(0, 3)].join('\n'))).toBe(false);
// Even with a lowercase model and a short path, the footer row would have to end in
// one of the hint's own closing words.
expect(blocked([...forgedDialog, ...composer('x', ' gpt-5.6 default · ~/x').slice(0, 3)].join('\n'))).toBe(false);
});
it('refuses the working view with the dialog quoted right above the status line', () => {
const screen = [...forgedDialog, '', '• Working (3s • esc to interrupt)', '', ...composer()].join('\n');
expect(blocked(screen)).toBe(false);
});
it('refuses a hint row without the option row above it, and an option row without the hint', () => {
expect(
blocked(
[' Would you like to run the following command?', ' Press enter to confirm or esc to cancel'].join('\n')
)
).toBe(false);
expect(blocked(['› 1. Yes, proceed (y)', ' 2. No (esc)'].join('\n'))).toBe(false);
});
it('stays linear on a hostile window', () => {
// Many option rows and long rows, ending in something that is almost the hint: the
// pattern must give up quickly, since it runs on a timer while a dialog is up.
const row = `› 1. ${'x'.repeat(300)}`;
const screen = [
...Array(BLOCKED_MAX_TAIL_LINES - 1).fill(row),
` Press enter to confirm ${'or '.repeat(60)}`,
].join('\n');
const started = performance.now();
for (let i = 0; i < 50; i++) blocked(screen);
expect(performance.now() - started).toBeLessThan(250);
expect(blocked(screen)).toBe(false);
});
it('caps the window at BLOCKED_MAX_TAIL_LINES whatever the caller asks', () => {
const dialog = fixture('exec-approval.txt');
const padded = `${dialog}\n${Array(BLOCKED_MAX_TAIL_LINES).fill(' filler').join('\n')}`;
expect(isBlockedScreen(padded, CODEX_BLOCKED, 10_000)).toBe(false);
});
it('answers false for an empty or unreadable capture', () => {
expect(isBlockedScreen('', CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
expect(isBlockedScreen(null, CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
});
});
// ─── The session's blocked state ────────────────────────────────────────────
/** Codex's own composer repaint: carries the glyph that arms the idle confirmation. */
const CODEX_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* What the pane shows, and how often the session has read it: every read is a synchronous
* `tmux capture-pane` on the live server.
*/
type FakeScreen = { text: string | null; captures?: number };
/** A session whose mux answers captures from `screen.text` (null = unreadable). */
function withFakePane(screen: FakeScreen, mode: SessionMode = 'codex'): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => {
screen.captures = (screen.captures ?? 0) + 1;
return screen.text;
},
} 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() },
} as ConstructorParameters<typeof Session>[0]);
}
/**
* The pane changes the only way a real one can: tmux redraws it to the attached client,
* which reaches the session as output. A cursor move and an erase, with no composer glyph
* in it, so it marks activity without arming the idle confirmation.
*/
function redraw(session: Session, screen: FakeScreen, text: string | null): void {
screen.text = text;
feed(session, '\x1b[2;1H\x1b[K');
}
/** Output, then quiet: the moment the idle confirmation reads the screen. */
function settle(session: Session): void {
for (let i = 0; i < 3; i++) {
feed(session, CODEX_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
function recordChanges(session: Session): Array<{ blocked: boolean; frame: string | null }> {
const changes: Array<{ blocked: boolean; frame: string | null }> = [];
session.on('blockedChanged', (b: boolean, frame: string | null) => changes.push({ blocked: b, frame }));
return changes;
}
describe('Session.screenBlocked', () => {
afterEach(() => {
vi.useRealTimers();
});
it('raises once when the pane settles on an approval, and reads the session idle', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
expect(changes).toHaveLength(1);
expect(changes[0].blocked).toBe(true);
// The frame it was read from rides along, as the approval card's context.
expect(changes[0].frame).toContain('Would you like to run the following command?');
// The dialog staying up is one dialog: no second raise from the timer or a repaint.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
settle(session);
expect(changes).toHaveLength(1);
});
it('calls a question dialog blocked and idle, not working', () => {
// Its key hints carry `esc to interrupt`, which is codex's working line: before the
// field the probe called this pane busy for as long as the question stayed up.
vi.useFakeTimers();
const session = withFakePane({ text: fixture('question.txt') });
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
});
it('clears after the dialog has been gone for BLOCKED_CLEAR_READS reads, without waiting for the turn', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
// Answered: codex resumes the turn, and nothing settles while it runs. The timer is
// what notices, and one read alone is not enough.
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * (BLOCKED_CLEAR_READS - 1));
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
// Nothing keeps reading a screen with no dialog on it.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 10);
expect(changes).toHaveLength(2);
});
it('does not flap on one read that misses a dialog still on screen', () => {
// A capture inside a full-frame repaint can come back without the footer. Clearing on
// it would raise again a moment later: a second alert, push and webhook for one dialog.
vi.useFakeTimers();
const dialog = fixture('exec-approval.txt');
const screen = { text: dialog as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
redraw(session, screen, dialog);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 3);
expect(session.screenBlocked).toBe(true);
expect(changes).toHaveLength(1);
});
it('keeps its state through an unreadable capture, in either direction', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
settle(session);
// The pane moved, so the session looks, and tmux answers nothing it can read.
redraw(session, screen, null);
const before = screen.captures!;
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
expect(screen.captures! - before).toBeGreaterThanOrEqual(5);
expect(session.screenBlocked).toBe(true);
// ...and it is still watching: once the screen reads again without the dialog, it clears.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('does not re-read a quiet pane every interval while a dialog sits unanswered', () => {
// Each read is a synchronous `tmux capture-pane` (about 8 ms of blocked event loop,
// measured 2026-10-14), and a dialog can sit unanswered overnight. It leaves the
// screen only through a repaint, which reaches the session as output, so a pane that
// drew nothing since the last reading that saw the dialog is still showing it.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
expect(session.screenBlocked).toBe(true);
const quietMs = 10 * 60_000;
const before = screen.captures!;
vi.advanceTimersByTime(quietMs);
// The safety-net read only, never one per BLOCKED_RECHECK_MS (that would be 300 here).
expect(screen.captures! - before).toBeLessThanOrEqual(quietMs / BLOCKED_QUIET_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
// The answer repaints the pane, and the clear comes as fast as before.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('takes the confirming read even when no output follows the reading that missed the dialog', () => {
// tmux updates the pane before it sends this client the repaint, so a capture can see
// the dialog gone while the session has been sent nothing yet. Skipping a quiet pane
// is right only while the last reading SAW the dialog: once one reading missed it,
// waiting for output could leave the alert up for the whole safety-net interval.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
vi.advanceTimersByTime(PANE_PROBE_MIN_INTERVAL_MS);
screen.text = fixture('after-approve.txt'); // and not one byte of output, from here on
(session as unknown as { _probePaneWorking(): boolean | null })._probePaneWorking();
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(false);
});
it('raises again for the next dialog after a clear', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
screen.text = fixture('edit-approval.txt');
settle(session);
expect(changes.map((c) => c.blocked)).toEqual([true, false, true]);
});
it('never reads a dialog off the screen of a CLI that declares no blockedLine', () => {
// Claude's dialogs arrive as hooks; gemini declares nothing. Same screen, no state.
vi.useFakeTimers();
for (const mode of ['claude', 'gemini'] as const) {
const session = withFakePane({ text: fixture('exec-approval.txt') }, mode);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked, mode).toBe(false);
expect(changes, mode).toHaveLength(0);
}
});
it('drops the state when a new pane starts, announcing it', () => {
vi.useFakeTimers();
const session = withFakePane({ text: fixture('trust-dialog.txt') });
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
(session as unknown as { _resetBuffers(): void })._resetBuffers();
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Codex's selection menus (the "Implement this plan?" prompt, the /model, /permissions and /review pickers,
* approvals, questions, the trust prompt) take PageUp and PageDown as "move the selection". Codeman turns
* the wheel into those keys for a Codex pane that does not track the mouse (codex-cli 0.162 and 0.164 never
* do), so a wheel notch walked the cursor through the options: one notch down landed on "No, stay in Plan
* mode", one up on "Yes, implement this plan". The pane must consume the wheel there and send nothing.
*
* The screens are the live `tmux capture-pane` frames in test/fixtures/codex-dialogs/.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const fixture = (name: string) => readFileSync(resolve(import.meta.dirname, 'fixtures/codex-dialogs', name), 'utf8');
/** A terminal whose screen shows `text`, padded to `rows` rows (what xterm holds for a hollow pane). */
function terminalShowing(text: string, rows = 40) {
const lines = text.replace(/\n+$/, '').split('\n');
// A capture taller than the pane keeps its bottom rows (the menu), a shorter one sits at the top.
const shown = lines.length > rows ? lines.slice(-rows) : lines;
const screen = Array.from({ length: rows }, (_, i) => shown[i] ?? '');
return {
cols: 100,
rows,
modes: { mouseTrackingMode: 'none' },
buffer: {
active: {
type: 'normal',
baseY: 0,
viewportY: 0,
length: rows,
getLine: (y: number) => (screen[y] === undefined ? undefined : { translateToString: () => screen[y] }),
},
},
};
}
function load() {
const CodemanApp = function CodemanApp(this: any) {};
const windowRef: Record<string, any> = { __codemanTranscriptPageKeys: PAGE_KEY_MODES };
const context = vm.createContext({
window: windowRef,
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 1_000 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
clearTimeout: () => undefined,
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} },
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: { isTouchDevice: () => true },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8'), context, { filename: 'terminal-ui.js' });
return { app: new (CodemanApp as any)(), windowRef };
}
/** Every dialog or menu a Codex pane can be sitting in, and the screens where PageUp must still page. */
const MENUS = [
'implement-plan.txt',
'model-popup.txt',
'permissions-popup.txt',
'review-popup.txt',
'exec-approval.txt',
'exec-approval-w36.txt',
'edit-approval.txt',
'question.txt',
'question-1of2.txt',
'question-notes.txt',
'trust-dialog.txt',
'trust-dialog-back.txt',
'trust-dialog-w40.txt',
'exec-approval-w44.txt',
'exec-approval-60x14.txt',
'exec-approval-long-command.txt',
'network-escalation-approval.txt',
'question-option2-selected.txt',
'question-2of2-submit-all.txt',
'question-notes-typed.txt',
'question-w36.txt', // the key hint one segment per row
'question-w60.txt',
];
const NOT_MENUS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt',
'slash-popup.txt',
'transcript-quotes-dialog.txt',
'composer-typed-dialog-text.txt',
'composer-typed-dialog-text-w60.txt',
];
describe('isCodexSelectionMenu on live captures', () => {
const { windowRef } = load();
const isMenu = (name: string) => windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminalShowing(fixture(name)));
it.each(MENUS)('reads %s as a menu', (name) => {
expect(isMenu(name), name).toBe(true);
});
it.each(NOT_MENUS)('does not read %s as a menu', (name) => {
expect(isMenu(name), name).toBe(false);
});
it('still reads a narrow question whose option descriptions wrap over many rows', () => {
// The selected option sat 16 rows up from the bottom at 36 columns; sentence-length descriptions on
// a phone-width pane push it further, and an earlier row cap lost it (the menu was missed and the
// wheel moved the selection again).
const rows = fixture('question-w36.txt').split('\n');
const at = rows.findIndex((row) => /^\s{0,2}› 1\./.test(row));
expect(at).toBeGreaterThan(-1);
rows.splice(at + 1, 0, ...Array.from({ length: 12 }, (_, i) => ` wrapped description row ${i + 1}`));
const terminal = terminalShowing(rows.join('\n'), 60);
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminal)).toBe(true);
});
it('is false for a terminal it cannot read', () => {
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(undefined)).toBe(false);
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu({ rows: 10, buffer: { active: { baseY: 0 } } })).toBe(
false
);
});
});
describe('the wheel over a Codex pane', () => {
function codexPane(screen: string) {
const { app } = load();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: false }]]);
app._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
app.terminal = terminalShowing(screen);
return { app, sent };
}
it('sends no page key while the "Implement this plan?" menu is open, and consumes the wheel', () => {
const { app, sent } = codexPane(fixture('implement-plan.txt'));
for (const lines of [-30, 30, -30, 30]) expect(app._maybePageCliTranscript({ shiftKey: false }, lines)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('still pages the transcript from the composer', () => {
const { app, sent } = codexPane(fixture('composer-typed.txt'));
expect(app._maybePageCliTranscript({ shiftKey: false }, -30)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual(['\x1b[5~']);
});
it('only looks at Codex: another page-key CLI showing the same text is paged as before', () => {
const { app, sent } = codexPane(fixture('implement-plan.txt'));
app.sessions.set('sess-1', { mode: 'opencode' });
app._maybePageCliTranscript({ shiftKey: false }, -30);
app._flushWheelSgrQueue();
expect(sent).toEqual(['\x1b[5~']);
});
});
describe('a tile asks the same question', () => {
it('consumes the wheel for an open Codex menu in its own terminal and session', () => {
const tile = readFileSync(resolve(publicDir, 'terminal-tile.js'), 'utf8');
expect(tile).toContain('app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })');
const { app } = load();
app.sessions = new Map([
['active', { mode: 'claude' }],
['tile', { mode: 'codex' }],
]);
app.terminal = terminalShowing(fixture('composer-typed.txt'));
const tileTerminal = terminalShowing(fixture('implement-plan.txt'));
// the tile's own terminal and session decide, not the active pane's
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'tile' })).toBe(true);
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'active' })).toBe(false);
expect(app._codexMenuOpen()).toBe(false);
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* @fileoverview The Codex rollout shape helpers shared by the Past Sessions
* scanner (codex-transcript.ts) and the offline history reader
* (local-history.ts): store location, message shapes and the injected-context
* rule. Both readers' own tests cover their use of these; this pins the shared
* contract itself.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { join } from 'node:path';
import {
codexContentText,
codexHomeDir,
codexMessageFromLine,
codexSessionsRoot,
isCodexInjectedContext,
} from '../src/codex-rollout-format.js';
const savedHome = process.env.CODEX_HOME;
afterEach(() => {
if (savedHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = savedHome;
});
describe('codex rollout format', () => {
it('resolves the store from CODEX_HOME, else ~/.codex', () => {
process.env.CODEX_HOME = '/custom/codex';
expect(codexHomeDir()).toBe('/custom/codex');
expect(codexSessionsRoot()).toBe(join('/custom/codex', 'sessions'));
process.env.CODEX_HOME = '';
expect(codexSessionsRoot()).toBe(join(process.env.HOME!, '.codex', 'sessions'));
});
it('reads all three message shapes with their role, shape and phase', () => {
expect(codexMessageFromLine({ type: 'event_msg', payload: { type: 'user_message', message: 'hi' } })).toEqual({
role: 'user',
text: 'hi',
shape: 'event',
phase: undefined,
injectionProne: false,
});
expect(
codexMessageFromLine({
type: 'event_msg',
payload: { type: 'agent_message', message: 'ok', phase: 'commentary' },
})
).toMatchObject({ role: 'assistant', shape: 'event', phase: 'commentary', injectionProne: false });
expect(
codexMessageFromLine({
type: 'event_msg',
payload: {
type: 'item_completed',
item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'done' }] },
},
})
).toMatchObject({ role: 'assistant', text: 'done', shape: 'item', phase: 'final_answer' });
expect(
codexMessageFromLine({
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'typed' }] },
})
).toMatchObject({ role: 'user', text: 'typed', shape: 'response', injectionProne: true });
expect(
codexMessageFromLine({
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'a' }] },
})
).toMatchObject({ role: 'assistant', shape: 'response', injectionProne: false });
});
it('ignores every other line, empty text and malformed input', () => {
for (const value of [
null,
42,
'text',
[],
{ type: 'session_meta', payload: { id: 'x' } },
{ type: 'response_item', payload: { type: 'message', role: 'developer', content: 'policy' } },
{ type: 'response_item', payload: { type: 'reasoning', summary: [] } },
{ type: 'event_msg', payload: { type: 'user_message', message: '' } },
{ type: 'event_msg', payload: { type: 'item_completed', item: { type: 'CommandExecution' } } },
])
expect(codexMessageFromLine(value)).toBeNull();
});
it('flattens only blocks that carry text', () => {
expect(codexContentText('plain')).toBe('plain');
expect(
codexContentText([
{ type: 'input_text', text: 'a' },
{ type: 'input_image', image_url: 'x' },
{ text: 'b' },
null,
])
).toBe('a\nb');
expect(codexContentText(undefined)).toBe('');
});
it('treats a heading or a tag as injected context, wherever the wrapper name is new', () => {
for (const text of [
'# AGENTS.md instructions for /repo',
'<environment_context>',
'<recommended_plugins>',
'<subagent_notification>',
'<codex_internal_context source="goal">',
'<image name=[Image #1]>',
' <turn_aborted>',
])
expect(isCodexInjectedContext(text)).toBe(true);
for (const text of ['fix the bug', 'You are a reviewer', '$skill run', 'a <b> tag later'])
expect(isCodexInjectedContext(text)).toBe(false);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { WebServer } from '../src/web/server.js';
// Adversarial browser layout fixture, not evidence of native Codex history:
// authentic CLI behavior is measured separately in an isolated live pane.
describe('tracked Codex wheel routing in the primary pane', () => {
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
});
it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
try {
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal);
await page.waitForTimeout(3500);
await page.evaluate(async () => {
const app = (window as any).app;
app.activeSessionId = 'codex-wheel-probe';
app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true });
(window as any).scrollInputs = [];
// Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal.
app._clientPointToCell = () => ({ col: 1, row: 1 });
app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data });
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve));
app.terminal.scrollToTop();
});
const fire = (shiftKey = false) =>
page.evaluate((shiftKey) => {
const t = (window as any).app.terminal;
t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true }));
}, shiftKey);
await fire();
await page.waitForTimeout(150);
const forwarded = await page.evaluate(() => {
const app = (window as any).app;
return {
inputs: (window as any).scrollInputs,
baseY: app.terminal.buffer.active.baseY,
viewportY: app.terminal.buffer.active.viewportY,
};
});
expect(forwarded.inputs.length).toBeGreaterThan(0);
expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true);
expect(
forwarded.inputs
.map((i: { data: string }) => i.data)
.join('')
.match(/\x1b\[<64;/g)
).toHaveLength(4);
expect(forwarded.viewportY).toBe(forwarded.baseY);
for (const shift of [false, true]) {
await page.evaluate((tracked) => {
const app = (window as any).app;
app.sessions.get(app.activeSessionId).cliMouseTracking = tracked;
app.terminal.scrollToBottom();
(window as any).scrollInputs = [];
}, shift);
await fire(shift);
await page.waitForTimeout(150);
const local = await page.evaluate(() => {
const b = (window as any).app.terminal.buffer.active;
return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY };
});
expect(local.inputs).toEqual([]);
expect(local.viewportY).toBeLessThan(local.baseY);
}
} finally {
await page.close();
}
});
});
@@ -0,0 +1,86 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { WebServer } from '../src/web/server.js';
describe('Command-K agent arrow keys in Chromium', () => {
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
});
it('cycles both directions at query boundaries while preserving text editing and selection', async () => {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
try {
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.openCommandPalette);
await page.waitForTimeout(3500);
await page.evaluate(() => {
const a = (window as any).app;
a.cases = [{ name: 'buildmesh', path: '/fixture/buildmesh' }];
a.sessions = new Map();
a.sessionOrder = [];
a._runMode = 'codex';
(window as any).__codemanCliCatalog = [
{ id: 'claude', label: 'Claude', enabled: true, kind: 'agent' },
{ id: 'codex', label: 'Codex', enabled: true, kind: 'agent' },
{ id: 'shell', label: 'Shell', enabled: true, kind: 'shell' },
];
a._runModeAvailableForCase = () => true;
a._sendInputEphemeral = () => {
throw new Error('Palette leaked terminal input');
};
a.run = () => {
throw new Error('Arrow key launched a session');
};
});
await page.keyboard.press('Control+k');
await page.locator('#commandPaletteModal.active').waitFor();
const input = page.locator('#commandPaletteSearch');
await input.fill('buildmesh');
const label = page.locator('.command-palette-agent-label');
expect(await label.innerText()).toBe('Codex');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Claude');
await page.keyboard.press('ArrowRight');
expect(await label.innerText()).toBe('Codex');
expect(await input.inputValue()).toBe('buildmesh');
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(9);
await page.keyboard.press('Home');
await page.keyboard.press('ArrowRight');
expect(await label.innerText()).toBe('Shell');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Codex');
await input.evaluate((e: HTMLInputElement) => e.setSelectionRange(4, 4));
await page.keyboard.press('ArrowLeft');
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(3);
await page.keyboard.press('ArrowRight');
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(4);
await page.keyboard.press('Shift+End');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Codex');
await page.keyboard.press('ArrowDown');
expect(
await page.evaluate(
() => (window as any).app.commandPaletteItems[(window as any).app.commandPaletteActiveIndex].type
)
).toBe('browse-sessions');
await page.keyboard.press('End');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Codex');
await page.keyboard.press('Escape');
expect(await page.locator('#commandPaletteModal').evaluate((e) => e.classList.contains('active'))).toBe(false);
expect(errors).toEqual([]);
} finally {
await page.close();
}
});
});
+359 -1
View File
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadPaletteHarness(overrides: Record<string, any> = {}) {
function loadPaletteHarness(overrides: Record<string, any> = {}, { sessionUi = false } = {}) {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};
const CodemanApp = function CodemanApp(this: any) {};
@@ -70,6 +70,12 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
// session-ui.js loads after panels-ui.js in index.html. Opt-in: it also brings
// the real selectQuickStartCase, which the case-pick tests above stub around.
if (sessionUi) {
const sessionUiSrc = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUiSrc, context, { filename: 'session-ui.js' });
}
const app = new (CodemanApp as any)();
app.sessions = new Map([
@@ -341,6 +347,358 @@ describe('Command-K session palette', () => {
});
});
const PALETTE_CATALOG = [
{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true },
{ id: 'shell', label: 'Shell', kind: 'shell', enabled: true },
{ id: 'codex', label: 'Codex', kind: 'agent', enabled: true },
{ id: 'opencode', label: 'OpenCode', kind: 'agent', enabled: true },
{ id: 'gemini', label: 'Gemini', kind: 'agent', enabled: false },
];
/** A palette whose agent list comes from the injected CLI catalog, codex not installed. */
function loadAgentPalette(catalog: any[] = PALETTE_CATALOG, options: { sessionUi?: boolean } = {}) {
const available: Record<string, boolean> = { claude: true, shell: true, codex: false, opencode: true, gemini: true };
const harness = loadPaletteHarness(
{ window: { __codemanCliCatalog: catalog, __codemanCliAvailable: available } },
options
);
// Mirrors settings-ui.js isCliAvailable(): unknown reads as available.
harness.app.isCliAvailable = (id: string) => available[id] !== false;
harness.app._runMode = 'claude';
return harness;
}
function arrow(key: 'ArrowLeft' | 'ArrowRight', caret: number, value = '') {
return {
key,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { value, selectionStart: caret, selectionEnd: caret },
};
}
describe('Command-K new-session agent picker', () => {
it('offers the enabled, installed CLIs from the registry catalog, in catalog order', () => {
const { app } = loadAgentPalette();
expect(app._commandPaletteAgentChoices().map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
});
it('starts on the Run button mode and labels the row from the catalog', () => {
const { app, elements } = loadAgentPalette();
app._runMode = 'opencode';
app.openCommandPalette();
const item = app.buildCommandPaletteItems('flux').find((i: any) => i.type === 'new-session');
expect(item).toMatchObject({ mode: 'opencode', subtitle: 'Run OpenCode in flux-player' });
expect(elements.commandPaletteList.innerHTML).toContain('data-palette-agent-cycle="next"');
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot opencode');
});
it('keeps the switch out of the row button accessible name and gives its tooltip a zh-CN entry', () => {
const { app, elements } = loadAgentPalette();
app.openCommandPalette();
const html = elements.commandPaletteList.innerHTML;
const title = 'Agent for the new session (Left/Right to change)';
// No nested buttons inside the row's <button>: the subtitle names the agent.
expect(html).toContain(`class="command-palette-agent" aria-hidden="true" title="${title}"`);
expect(html).not.toContain('role="button"');
expect(html).not.toContain('aria-label=');
const i18n = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
expect(i18n.split(`'${title}':`).length - 1).toBe(1);
});
it('falls back to the first offered agent when the Run button mode is not offered', () => {
const { app } = loadAgentPalette();
app._runMode = 'codex'; // not installed
app.openCommandPalette();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles the agent with Right/Left on the new-session row, wrapping', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].type).toBe('new-session');
const right = arrow('ArrowRight', 7, 'nomatch');
await listeners['search:keydown'](right);
expect(right.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'shell' });
expect(elements.commandPaletteList.innerHTML).toContain('Run Shell in');
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
});
it('cycles both ways at the end of a typed case query without moving its caret', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const left = arrow('ArrowLeft', 7, 'nomatch');
await listeners['search:keydown'](left);
expect(left.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode');
const right = arrow('ArrowRight', 7, 'nomatch');
await listeners['search:keydown'](right);
expect(right.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
});
it('leaves Left/Right to the caret inside the query', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const left = arrow('ArrowLeft', 3, 'nomatch');
const right = arrow('ArrowRight', 3, 'nomatch');
await listeners['search:keydown'](left);
await listeners['search:keydown'](right);
expect(left.preventDefault).not.toHaveBeenCalled();
expect(right.preventDefault).not.toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
});
it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])(
'preserves %s arrow editing at query boundaries',
async (modifier) => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true };
await listeners['search:keydown'](event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
}
);
it('does not cycle while a session row is highlighted', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
expect(app.commandPaletteItems[0].type).toBe('session');
const right = arrow('ArrowRight', 0);
await listeners['search:keydown'](right);
expect(right.preventDefault).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles from the row arrows without launching', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
const cycle = { dataset: { paletteAgentCycle: 'next' } };
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { closest: (sel: string) => (sel === '[data-palette-agent-cycle]' ? cycle : null) },
};
listeners['list:click'](event);
expect(app.run).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('shell');
});
it('launches the chosen agent once, without changing the Run button mode', async () => {
const { app } = loadAgentPalette();
app.setRunMode = vi.fn();
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(2); // claude -> shell -> opencode
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(app.run).toHaveBeenCalledTimes(1);
expect(modeDuringRun).toBe('opencode');
expect(app._runMode).toBe('claude');
expect(app.setRunMode).not.toHaveBeenCalled();
});
it('restores the Run button mode even when the launch throws', async () => {
const { app } = loadAgentPalette();
app.run = vi.fn(async () => {
throw new Error('boom');
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(1);
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await expect(app.activateCommandPaletteItem()).rejects.toThrow('boom');
expect(app._runMode).toBe('claude');
});
it('draws no picker when only one agent is offered', () => {
const { app, elements } = loadAgentPalette([{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true }]);
app.openCommandPalette();
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
});
it('keeps the Run button mode when a single other agent is offered', async () => {
// Only Shell is offered while the Run button names claude: with nothing to
// pick between, the row keeps the Run button's mode, as before the picker
// existed, so run() answers a missing claude with its install hint instead
// of the palette silently starting Shell.
const { app, elements } = loadAgentPalette(PALETTE_CATALOG.slice(0, 2));
app.isCliAvailable = (id: string) => id !== 'claude';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app.commandPaletteItems[index]).toMatchObject({ mode: 'claude', subtitle: 'Run Claude in plex-previews' });
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('claude');
expect(app._runMode).toBe('claude');
});
});
/** The toolbar Run menu's entries, as renderRegistryRunOptions() and index.html draw them. */
function fakeRunMenu(modes: string[]) {
const options = modes.map((mode) => ({ dataset: { mode }, style: { display: 'PRISTINE' } }));
return {
options,
querySelectorAll: (selector: string) => (selector === '.run-mode-option[data-mode]' ? options : []),
querySelector: () => null,
prepend: vi.fn(),
};
}
const shownByRunMenu = (menu: ReturnType<typeof fakeRunMenu>) =>
menu.options.filter((option) => option.style.display === 'flex').map((option) => option.dataset.mode);
/**
* A palette with session-ui.js loaded, so the agent list goes through the Run
* menu's real availability rule. Host flags as in loadAgentPalette (no codex
* here); the active case is a Docker case with the given `docker` record.
*/
function loadDockerPalette(docker: Record<string, any>) {
const harness = loadAgentPalette(PALETTE_CATALOG, { sessionUi: true });
harness.app.cases = [
{ name: 'plex-previews' },
{ name: 'far', location: 'remote' },
{ name: 'dock', location: 'docker', docker },
];
harness.elements.quickStartCase.value = 'dock';
harness.app.selectQuickStartCase = vi.fn();
// The Run menu's live container probe is a docker exec round trip; here it
// never lands, which is also what a probe still in flight looks like.
harness.app._probeDockerCaseModes = vi.fn();
return harness;
}
const ADOPTED = { owned: false, hostId: 'local', container: 'dev-box', availableModes: ['shell', 'claude', 'codex'] };
describe('Command-K new-session agents follow the Run menu for the row case', () => {
it('offers a Docker case its container agents and starts on a container-only Run mode', async () => {
// codex is missing on the host but ships in the container, and the Run
// button is on it (picked from the Run menu, which gates on the container).
const { app, elements } = loadDockerPalette(ADOPTED);
app._runMode = 'codex';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['claude', 'shell', 'codex']);
expect(app.commandPaletteItems[index]).toMatchObject({
caseName: 'dock',
mode: 'codex',
subtitle: 'Run Codex in dock',
});
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot codex');
// The same agents the Run menu shows for this case.
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex']);
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('codex');
expect(app._runMode).toBe('codex');
});
it('offers every enabled agent for an owned container nothing has probed yet, and then follows the probe', () => {
// An owned container runs the base image, which ships every CLI, and before
// its first session there is nothing to probe: like the Run menu, an unknown
// list does not gate, so a probe still in flight cannot empty the row.
const { app } = loadDockerPalette({ owned: true, hostId: 'local', container: 'codeman-dock' });
app._runMode = 'codex';
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'codex',
'opencode',
]);
expect(app.commandPaletteItems.find((i: any) => i.type === 'new-session').mode).toBe('codex');
// The palette reads what the Run menu learned; it never starts a docker exec itself.
expect(app._probeDockerCaseModes).not.toHaveBeenCalled();
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex', 'opencode']);
// Once the live probe has landed, both follow it.
app._dockerCaseModes = { dock: ['shell', 'opencode'] };
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['shell', 'opencode']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['opencode']);
});
it('keeps the host rule for local and remote cases, and follows the case the query names', () => {
const { app, elements } = loadDockerPalette(ADOPTED);
elements.quickStartCase.value = 'plex-previews';
app._runMode = 'codex'; // missing on the host
const row = () => app.commandPaletteItems.find((i: any) => i.type === 'new-session');
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('plex-previews').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'opencode',
]);
expect(app._commandPaletteAgentChoices('far').map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
expect(row()).toMatchObject({ caseName: 'plex-previews', mode: 'claude' });
// Typing the Docker case's name moves the row there, where the container
// has the Run button's codex, so the row starts on it.
elements.commandPaletteSearch.value = 'dock';
app.renderCommandPalette();
expect(row()).toMatchObject({ caseName: 'dock', mode: 'codex' });
});
});
describe('Session Manager unified list', () => {
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
const { app, elements } = loadPaletteHarness({
+59
View File
@@ -0,0 +1,59 @@
/**
* @fileoverview The snapshot origin must survive a hostname change (a Docker container gets a new
* hostname on every recreate) and still differ between data dirs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const host = vi.hoisted(() => ({ name: 'container-a' }));
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<typeof import('node:os')>()),
hostname: () => host.name,
}));
import {
configBackupOrigin,
createConfigBackup,
listConfigBackups,
pruneConfigBackups,
restoreConfigBackup,
} from '../src/config-backup.js';
let root: string;
let data: string;
let backups: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'cfg-origin-'));
data = join(root, 'data');
backups = join(root, 'backups');
mkdirSync(data, { recursive: true });
writeFileSync(join(data, 'settings.json'), '{"a":1}');
host.name = 'container-a';
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('configBackupOrigin', () => {
it('is made once, kept in the data dir, and differs between data dirs', () => {
const a = configBackupOrigin(data);
expect(configBackupOrigin(data)).toBe(a);
expect(readFileSync(join(data, 'config-backup-id'), 'utf8').trim()).toBe(a);
const other = join(root, 'other');
expect(configBackupOrigin(other)).not.toBe(a);
});
it('keeps snapshots listed, restorable and prunable after the hostname changes', () => {
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: 1_000_000 }) as {
id: string;
};
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: 2_000_000 });
host.name = 'container-b'; // the container was recreated
const origin = configBackupOrigin(data);
expect(listConfigBackups(backups, origin)).toHaveLength(2);
writeFileSync(join(data, 'settings.json'), '{"a":2}'); // a change, so the restore takes a pre-restore copy
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).not.toThrow();
const removed = pruneConfigBackups(backups, { keepCount: 1, keepDays: 0, origin });
expect(removed).toHaveLength(2); // 3 now: the restore took a pre-restore copy
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* @fileoverview Restoring settings.json from inside App Settings must refill the form, or Save
* would PUT the pre-restore values back over the restored file. Evaluates the real settings-ui.js
* against a stub app.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
function makeApp(
restored: string[],
confirmed = true,
reply: { ok: boolean; body: unknown } = { ok: true, body: { success: true, data: { restored, restartRequired: [] } } }
) {
const calls: string[] = [];
const confirms: string[] = [];
const toasts: Array<[string, string]> = [];
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: {},
confirm: (m: string) => (confirms.push(m), confirmed),
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
Object.assign(app, {
_configBackups: [
{
id: 'cfg-1',
files: [{ name: 'settings.json' }, { name: 'users.json' }, { name: '.env' }, { name: 'clis.json' }],
},
],
_apiPost: async () => ({ ok: reply.ok, json: async () => reply.body }),
loadAppSettingsFromServer: async () => void calls.push('reload-settings'),
openAppSettings: () => void calls.push('refill-form'),
loadConfigBackups: () => void calls.push('reload-list'),
showToast: (m: string, type: string) => void toasts.push([m, type]),
});
return { app, calls, confirms, toasts };
}
describe('restoreConfigBackup (Settings UI)', () => {
it('re-reads settings into the form when settings.json was restored', async () => {
const { app, calls } = makeApp(['settings.json', 'clis.json']);
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
});
it('leaves the form alone when settings.json was not part of the restore', async () => {
const { app, calls } = makeApp(['clis.json']);
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual(['reload-list']);
});
it('a restore that failed partway still refills the form when settings.json was among the files written', async () => {
const { app, calls, toasts } = makeApp([], true, {
ok: false,
body: {
success: false,
errorCode: 'OPERATION_FAILED',
error: 'Restore incomplete: could not write clis.json (EISDIR); settings.json restored',
data: {
restored: ['settings.json'],
failed: [{ name: 'clis.json', error: 'EISDIR' }],
safetySnapshot: 'cfg-2',
restartRequired: ['settings.json'],
},
},
});
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
expect(toasts).toHaveLength(1);
expect(toasts[0][1]).toBe('error');
expect(toasts[0][0]).toContain('clis.json');
});
it('a refused restore (nothing written) only reports the error', async () => {
const { app, calls, toasts } = makeApp([], true, {
ok: false,
body: { success: false, errorCode: 'CONFLICT', error: 'settings.json does not match its checksum' },
});
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual([]);
expect(toasts).toEqual([['settings.json does not match its checksum', 'error']]);
});
it('names the files being replaced and calls out the credential ones', async () => {
const { app, confirms } = makeApp(['settings.json'], false);
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(confirms[0]).toContain('settings.json, users.json, .env, clis.json');
expect(confirms[0]).toContain('credentials');
expect(confirms[0]).toMatch(/users\.json, \.env\)/);
});
});
describe('loadConfigBackups (retention fields)', () => {
function load(saved: Record<string, unknown>) {
const inputs: Record<string, { value: string; placeholder: string }> = {
appSettingsConfigBackupKeepCount: { value: '20', placeholder: '' },
appSettingsConfigBackupKeepDays: { value: '30', placeholder: '' },
appSettingsConfigBackupDir: { value: '', placeholder: '' },
};
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => inputs[id] ?? null },
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
Object.assign(app, {
$: () => ({ textContent: '', innerHTML: '' }),
loadAppSettingsFromStorage: () => saved,
_api: async () => ({
ok: true,
json: async () => ({
success: true,
data: { settings: { keepCount: 100, keepDays: 0 }, dir: '/d', defaultDir: '/d', backups: [] },
}),
}),
});
return { app, inputs };
}
it('shows the retention the passes use when settings.json has none of the keys (a reset file)', async () => {
const { app, inputs } = load({});
await (app.loadConfigBackups as () => Promise<void>)();
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe(100);
expect(inputs.appSettingsConfigBackupKeepDays.value).toBe(0);
});
it('leaves the form alone when the saved settings state the retention', async () => {
const { app, inputs } = load({ configBackupKeepCount: 7, configBackupKeepDays: 9 });
await (app.loadConfigBackups as () => Promise<void>)();
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it
});
});
describe('Save and the displayed retention', () => {
function payload(
prev: Record<string, unknown>,
touched: { count?: boolean; days?: boolean },
count = '100',
days = '0'
) {
const inputs: Record<string, { value: string; dataset: Record<string, string> }> = {
appSettingsConfigBackupKeepCount: { value: count, dataset: touched.count ? { touched: '1' } : {} },
appSettingsConfigBackupKeepDays: { value: days, dataset: touched.days ? { touched: '1' } : {} },
};
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => inputs[id] },
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
return (app._configBackupRetentionPayload as (p: Record<string, unknown>) => Record<string, unknown>).call(
app,
prev
);
}
it('leaves out a retention value that was only displayed (a reset settings.json)', () => {
expect(payload({}, {})).toEqual({});
});
it('sends a value that was saved before, or edited here, and only that one', () => {
expect(payload({ configBackupKeepCount: 100 }, {})).toEqual({ configBackupKeepCount: 100 });
expect(payload({}, { days: true })).toEqual({ configBackupKeepDays: 0 });
expect(payload({}, { count: true, days: true }, '5', '7')).toEqual({
configBackupKeepCount: 5,
configBackupKeepDays: 7,
});
});
it('clamps like the form always did', () => {
expect(payload({}, { count: true, days: true }, '1', '99999')).toEqual({
configBackupKeepCount: 2,
configBackupKeepDays: 3650,
});
});
});
+686
View File
@@ -0,0 +1,686 @@
/**
* @fileoverview Config backups (src/config-backup.ts): what is tracked, when a snapshot is made,
* how retention prunes, and that restore verifies before it writes. Pure fs over temp dirs;
* port: N/A.
*/
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
CONFIG_BACKUP_FILES,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
createConfigBackup,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
normalizeBackupDir,
pruneConfigBackups,
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
configBackupOrigin,
latestBackupRetention,
sweepConfigBackupTemps,
} from '../src/config-backup.js';
let root: string;
let data: string;
let backups: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'cfg-backup-'));
data = join(root, 'data');
backups = join(root, 'backups');
mkdirSync(data);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
const put = (name: string, text: string) => writeFileSync(join(data, name), text);
const DAY = 86_400_000;
const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
describe('settings', () => {
it('defaults to ON, 20 copies, 30 days, default folder', () => {
expect(readConfigBackupSettings({})).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
expect(readConfigBackupSettings(null).enabled).toBe(true);
});
it('only an explicit false turns it off, and numbers are clamped to their ranges', () => {
expect(readConfigBackupSettings({ configBackupEnabled: false }).enabled).toBe(false);
const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 });
expect(s.keepCount).toBe(500);
expect(s.keepDays).toBe(0);
expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(2);
expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20);
expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7);
});
it('expands ~ and refuses anything that is not an absolute path', () => {
expect(normalizeBackupDir('~/cfg', '/home/u')).toBe('/home/u/cfg');
expect(normalizeBackupDir('/mnt/backups/codeman', '/home/u')).toBe('/mnt/backups/codeman');
for (const bad of ['', ' ', 'relative/dir', './here', undefined, 42, '/x\0y']) {
expect(normalizeBackupDir(bad as never, '/home/u'), String(bad)).toBeNull();
}
});
it('uses <data dir>/backups/config by default and never the data dir itself', () => {
expect(resolveConfigBackupDir(readConfigBackupSettings({}), '/d')).toBe(defaultConfigBackupDir('/d'));
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/elsewhere' }), '/d')).toBe(
'/elsewhere'
);
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/d' }), '/d')).toBe(
defaultConfigBackupDir('/d')
);
});
});
describe('createConfigBackup', () => {
it('snapshots only the tracked files that exist, with a manifest', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
put('state.json', '{"runtime":true}'); // not config: must not be copied
put('hook-secret', 'x'); // regenerated: must not be copied
const r = createConfigBackup({
dataDir: data,
backupDir: backups,
reason: 'startup',
appVersion: '1.2.3',
now: T0,
});
expect(r.status).toBe('created');
const [b] = listConfigBackups(backups);
expect(b.files.map((f) => f.name).sort()).toEqual(['clis.json', 'settings.json']);
expect(b.reason).toBe('startup');
expect(b.appVersion).toBe('1.2.3');
expect(b.createdAt).toBe(T0);
expect(existsSync(join(backups, b.id, 'state.json'))).toBe(false);
expect(readFileSync(join(backups, b.id, 'settings.json'), 'utf8')).toBe('{"a":1}');
});
it('tracks a fixed allowlist', () => {
expect([...CONFIG_BACKUP_FILES]).toContain('settings.json');
expect([...CONFIG_BACKUP_FILES]).not.toContain('state.json');
expect([...CONFIG_BACKUP_FILES]).not.toContain('hook-secret');
});
it('makes no second snapshot while nothing changed, and one when something does', () => {
put('settings.json', '{"a":1}');
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
expect(listConfigBackups(backups)).toHaveLength(1);
put('settings.json', '{"a":2}');
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'created'
);
expect(listConfigBackups(backups)).toHaveLength(2);
});
it('does not count a self-refreshed discovery timestamp as a change', () => {
const hosts = (stamp: string, model = 'm1') =>
JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] });
put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z'));
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
// The 5-minute discovery poll rewrites only the timestamp: still the same state.
put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z'));
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
expect(listConfigBackups(backups)).toHaveLength(1);
// A real edit next to a refreshed timestamp is still a change, stored exactly as written.
const edited = hosts('2026-10-12T08:54:22.025Z', 'm2');
put('custom-model-hosts.json', edited);
const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 });
expect(third.status).toBe('created');
expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited);
});
it.each([
[
'webviews.json',
(n: number) =>
JSON.stringify([
{
id: 'w1',
name: 'Grafana',
url: 'http://localhost:3001',
embedMode: 'proxy',
trusted: false,
createdAt: 1,
lastOpenedAt: n,
},
]),
'name',
],
[
'docker-cases.json',
(n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]),
'hostId',
],
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
[
'intents.json',
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
'goals',
],
])('does not count activity in %s as a change, but does count an edit next to it', (name, make, editKey) => {
put(name, make(1));
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put(name, make(2));
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 })).toEqual({
status: 'unchanged',
id: (first as { id: string }).id,
});
put(name, make(3).replace(new RegExp(`"${editKey}":"[^"]*"`), `"${editKey}":"edited"`));
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'created'
);
});
it('compares a tracked file that is not valid JSON by its bytes', () => {
put('custom-model-hosts.json', 'not json lastDiscoveredAt 1');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put('custom-model-hosts.json', 'not json lastDiscoveredAt 2');
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe(
'created'
);
});
it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 });
const forced = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: T0 });
expect(forced.status).toBe('created');
const ids = listConfigBackups(backups).map((b) => b.id);
expect(new Set(ids).size).toBe(2);
});
it('reports empty, creating nothing, when no tracked file exists', () => {
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x' })).toEqual({ status: 'empty' });
expect(existsSync(backups)).toBe(false);
});
it('keeps snapshots private: 0700 folder and 0600 files', () => {
put('settings.json', '{}');
put('.env', 'CODEMAN_PASSWORD=secret');
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
const id = (r as { id: string }).id;
expect(statSync(join(backups, id)).mode & 0o777).toBe(0o700);
expect(statSync(join(backups, id, '.env')).mode & 0o777).toBe(0o600);
expect(statSync(join(backups, id, 'manifest.json')).mode & 0o777).toBe(0o600);
});
it('leaves no temp folder behind', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
const stray = readdirSync(backups).filter((n) => n.startsWith('.tmp-'));
expect(stray).toEqual([]);
});
});
describe('retention', () => {
const makeN = (n: number) => {
for (let i = 0; i < n; i++) {
put('settings.json', JSON.stringify({ i }));
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + i * DAY });
}
};
it('keeps the newest keepCount', () => {
makeN(6);
const removed = pruneConfigBackups(backups, { keepCount: 3, keepDays: 0, now: T0 + 6 * DAY });
expect(removed).toHaveLength(3);
const left = listConfigBackups(backups);
expect(left).toHaveLength(3);
expect(left.map((b) => b.createdAt)).toEqual([T0 + 5 * DAY, T0 + 4 * DAY, T0 + 3 * DAY]);
});
it('drops anything older than keepDays, but never the newest', () => {
makeN(4); // T0 .. T0+3d
// At T0+100d everything is older than 30 days: the newest still survives.
const removed = pruneConfigBackups(backups, { keepCount: 50, keepDays: 30, now: T0 + 100 * DAY });
expect(removed).toHaveLength(3);
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 3 * DAY]);
});
it('applies both limits together and keepDays 0 means no age limit', () => {
makeN(5);
expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]);
const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY });
// Newest first: T0+4d, +3d, +2d, +1d, T0. A snapshot ages from its successor: T0+1d was live
// until T0+2d, which sits exactly on the cutoff (kept); only T0 is over the count.
expect(removed.length).toBe(1);
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([
T0 + 4 * DAY,
T0 + 3 * DAY,
T0 + 2 * DAY,
T0 + DAY,
]);
});
it('keeps the last good copy when the first change comes after a quiet month', () => {
put('settings.json', '{"theme":"good"}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'startup', now: T0 });
put('settings.json', '{}'); // the bad write, 40 days later
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 40 * DAY });
pruneConfigBackups(backups, { keepCount: 20, keepDays: 30, now: T0 + 40 * DAY });
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 40 * DAY, T0]);
});
it('never deletes a folder it did not make', () => {
makeN(3);
mkdirSync(join(backups, 'photos'));
writeFileSync(join(backups, 'photos', 'a.jpg'), 'x');
mkdirSync(join(backups, 'cfg-20200101-000000')); // right name, no manifest: not ours
writeFileSync(join(backups, 'notes.txt'), 'keep');
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 500 * DAY });
expect(existsSync(join(backups, 'photos', 'a.jpg'))).toBe(true);
expect(existsSync(join(backups, 'cfg-20200101-000000'))).toBe(true);
expect(existsSync(join(backups, 'notes.txt'))).toBe(true);
expect(listConfigBackups(backups)).toHaveLength(1);
});
it('does nothing for a folder that does not exist', () => {
expect(pruneConfigBackups(join(root, 'nope'), { keepCount: 1, keepDays: 1 })).toEqual([]);
});
});
describe('restoreConfigBackup', () => {
it('puts the files back, atomically, after taking a pre-restore snapshot', () => {
put('settings.json', '{"good":true}');
put('clis.json', '{"clis":{"grok":{"enabled":true}}}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"polluted":true}');
rmSync(join(data, 'clis.json'));
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
expect(r.restored.sort()).toEqual(['clis.json', 'settings.json']);
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"good":true}');
expect(readFileSync(join(data, 'clis.json'), 'utf8')).toBe('{"clis":{"grok":{"enabled":true}}}');
expect(statSync(join(data, 'settings.json')).mode & 0o777).toBe(0o600);
// The state it replaced is recoverable.
expect(r.safetySnapshot).toBeTruthy();
const safety = listConfigBackups(backups).find((b) => b.id === r.safetySnapshot)!;
expect(safety.reason).toBe('pre-restore');
expect(readFileSync(join(backups, safety.id, 'settings.json'), 'utf8')).toBe('{"polluted":true}');
});
it('refuses a snapshot whose file no longer matches its checksum, changing nothing', () => {
put('settings.json', '{"good":true}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
chmodSync(join(backups, made.id, 'settings.json'), 0o600);
writeFileSync(join(backups, made.id, 'settings.json'), '{"tampered":true}');
put('settings.json', '{"live":true}');
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).toThrow(
ConfigBackupCorruptError
);
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"live":true}');
expect(listConfigBackups(backups)).toHaveLength(1); // no pre-restore snapshot for a refused restore
});
it('rejects unknown ids and anything that is not a snapshot name (no path traversal)', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
for (const id of ['cfg-19990101-000000', '../data', '..', 'photos', 'cfg-20261001-120000/../../x']) {
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id }), id).toThrow(
ConfigBackupNotFoundError
);
}
});
it('reuses the newest backup as the undo copy when the live files already equal it', () => {
put('settings.json', '{"a":1}');
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"a":2}');
const newest = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + DAY }) as {
id: string;
};
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: first.id, now: T0 + 2 * DAY });
expect(r.safetySnapshot).toBe(newest.id);
expect(listConfigBackups(backups).map((b) => b.id)).toEqual([newest.id, first.id]); // no duplicate
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"a":1}');
});
it('a file it cannot write does not stop the others, and the error names both lists', () => {
put('settings.json', '{"good":true}');
put('clis.json', '{"clis":{}}');
put('webhook.json', '{"enabled":false}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"polluted":true}');
put('webhook.json', '{"enabled":true}');
rmSync(join(data, 'clis.json'));
mkdirSync(join(data, 'clis.json')); // between the other two in the tracked list
let caught: unknown;
try {
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ConfigBackupPartialRestoreError);
const err = caught as ConfigBackupPartialRestoreError;
expect(err.restored).toEqual(['settings.json', 'webhook.json']);
expect(err.failed.map((f) => f.name)).toEqual(['clis.json']);
expect(err.safetySnapshot).toBeTruthy();
expect(readFileSync(join(data, 'webhook.json'), 'utf8')).toBe('{"enabled":false}');
expect(readdirSync(data).filter((n) => n.includes('.restore-'))).toEqual([]); // no temp left behind
});
it('only ever writes allowlisted names, even if a manifest lists others', () => {
put('settings.json', '{"a":1}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
const mpath = join(backups, made.id, 'manifest.json');
const m = JSON.parse(readFileSync(mpath, 'utf8'));
m.files.push({ name: '../escape.json', bytes: 1, sha256: 'x' }, { name: 'state.json', bytes: 1, sha256: 'x' });
writeFileSync(mpath, JSON.stringify(m));
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id });
expect(existsSync(join(root, 'escape.json'))).toBe(false);
expect(existsSync(join(data, 'state.json'))).toBe(false);
});
});
describe('diffConfigBackups', () => {
const snap = (now: number) => {
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 't', force: true, now });
if (r.status !== 'created') throw new Error('expected a snapshot');
return r.id;
};
const diff = (from: string, to: string) => diffConfigBackups({ dataDir: data, backupDir: backups, from, to });
const file = (d: ReturnType<typeof diff>, name: string) => d.files.find((f) => f.name === name)!;
it('lists key-level changes between two backups', () => {
put('settings.json', JSON.stringify({ displayName: 'a', theme: { mode: 'dark' }, tags: ['x'] }));
const a = snap(T0);
put('settings.json', JSON.stringify({ displayName: 'b', theme: { mode: 'dark' }, tags: ['x', 'y'], extra: 1 }));
const b = snap(T0 + 1000);
const f = file(diff(a, b), 'settings.json');
expect(f.status).toBe('changed');
expect(f.changes).toEqual([
{ path: 'displayName', kind: 'changed', before: '"a"', after: '"b"' },
{ path: 'extra', kind: 'added', after: '1' },
{ path: 'tags[1]', kind: 'added', after: '"y"' },
]);
});
it('compares a backup with the running config, and reports files that appeared or vanished', () => {
put('settings.json', '{"a":1}');
put('webhook.json', '{"url":"u"}');
const a = snap(T0);
put('settings.json', '{"a":2}');
rmSync(join(data, 'webhook.json'));
put('intents.json', '{"i":true}');
const d = diff(a, 'current');
expect(d.changed).toBe(3);
expect(file(d, 'settings.json').changes).toEqual([{ path: 'a', kind: 'changed', before: '1', after: '2' }]);
expect(file(d, 'webhook.json').status).toBe('removed');
expect(file(d, 'intents.json').status).toBe('added');
expect(diff(a, a).changed).toBe(0);
});
it('never returns a webhook URL, captured prompts, or URL userinfo and query', () => {
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/oldsecret' }));
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw1'] }] }));
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t1#f' }));
const a = snap(T0);
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/newsecret' }));
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw2'] }] }));
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t2#f' }));
const d = diff(a, 'current');
const json = JSON.stringify(d);
for (const s of ['secret', 'pw1', 'pw2', 'user:pw', 'token=', 'hooks.slack']) expect(json).not.toContain(s);
expect(file(d, 'webhook.json').changes.every((c) => c.redacted)).toBe(true);
expect(file(d, 'intents.json').changes.every((c) => c.redacted)).toBe(true);
});
it('never returns a credential value, only that it differs', () => {
put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n');
put('users.json', JSON.stringify({ admin: { name: 'root' } }));
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-old', url: 'http://x' }]));
const a = snap(T0);
put('.env', 'SECRET_ONE=hunter3\nOTHER=abd\n');
put('users.json', JSON.stringify({ admin: { name: 'rooted' } }));
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-new', url: 'http://y' }]));
const d = diff(a, 'current');
const json = JSON.stringify(d);
for (const secret of ['hunter', 'abc', 'abd', 'root', 'sk-old', 'sk-new']) expect(json).not.toContain(secret);
expect(file(d, '.env').changes.every((c) => c.redacted)).toBe(true);
expect(file(d, 'custom-model-hosts.json').changes).toEqual([
{ path: '[0].apiKey', kind: 'changed', redacted: true },
{ path: '[0].url', kind: 'changed', before: '"http://x"', after: '"http://y"' },
]);
});
it('does not count a self-refreshed discovery timestamp as a difference', () => {
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 1 }]));
const a = snap(T0);
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 2 }]));
const d = diff(a, 'current');
expect(file(d, 'custom-model-hosts.json').status).toBe('unchanged');
expect(d.changed).toBe(0);
});
it('reports a file that is not JSON as differing, without its content', () => {
put('settings.json', 'not json at all');
const a = snap(T0);
put('settings.json', 'still not json');
const f = file(diff(a, 'current'), 'settings.json');
expect(f).toMatchObject({ status: 'changed', detail: 'bytes', changes: [] });
});
it('caps the changes listed per file', () => {
put('settings.json', '{}');
const a = snap(T0);
put('settings.json', JSON.stringify(Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`k${i}`, i]))));
const f = file(diff(a, 'current'), 'settings.json');
expect(f.changes).toHaveLength(200);
expect(f.truncated).toBe(true);
});
it('refuses unknown ids, path traversal and a tampered snapshot', () => {
put('settings.json', '{"a":1}');
const a = snap(T0);
expect(() => diff('cfg-20200101-000000', 'current')).toThrow(ConfigBackupNotFoundError);
expect(() => diff('../etc', 'current')).toThrow(ConfigBackupNotFoundError);
writeFileSync(join(backups, a, 'settings.json'), '{"a":99}');
expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError);
});
});
describe('createConfigBackup: a snapshot dated ahead of the clock', () => {
it('is not written again on every pass once the clock is corrected', () => {
put('settings.json', '{"v":0}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put('settings.json', '{"v":1}');
const ahead = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2 * 3_600_000 });
expect(ahead.status).toBe('created');
put('settings.json', '{"v":2}'); // the clock is back at T0 + 1 minute
const corrected = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 60_000 });
expect(corrected.status).toBe('created');
const [newest, second] = listConfigBackups(backups);
expect(newest.id).toBe((corrected as { id: string }).id); // dated no earlier than the one before it
expect(newest.createdAt).toBeGreaterThan(second.createdAt);
for (let i = 0; i < 24; i++) {
const r = createConfigBackup({
dataDir: data,
backupDir: backups,
reason: 'auto',
now: T0 + 120_000 + i * 300_000,
});
expect(r.status, `pass ${i}`).toBe('unchanged');
}
expect(listConfigBackups(backups)).toHaveLength(3);
});
});
describe('instances sharing one backup folder', () => {
it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => {
const other = join(root, 'other-data');
mkdirSync(other, { recursive: true });
put('settings.json', '{"a":1}');
writeFileSync(join(other, 'settings.json'), '{"b":2}');
const mine = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
const theirs = createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(theirs.status).toBe('created');
// Each side's change check compares with its own newest snapshot, so a second tick is a no-op.
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'unchanged'
);
expect(createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 3000 }).status).toBe(
'unchanged'
);
expect(listConfigBackups(backups, configBackupOrigin(data))).toHaveLength(1);
expect(
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 999 * DAY, origin: configBackupOrigin(data) })
).toEqual([]);
expect(listConfigBackups(backups)).toHaveLength(2);
const theirId = (theirs as { id: string }).id;
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: theirId })).toThrow(
ConfigBackupNotFoundError
);
expect((mine as { id: string }).id).not.toBe(theirId);
});
it('a restore reports tracked files it left alone, and sweeps only old temp folders', () => {
put('settings.json', '{"a":1}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('webhook.json', '{"url":"u"}');
expect(restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }).untouched).toEqual(['webhook.json']);
const stale = join(backups, '.tmp-cfg-20200101-000000-99999');
mkdirSync(stale);
sweepConfigBackupTemps(backups, Date.now() + 2 * 3_600_000);
expect(existsSync(stale)).toBe(false);
});
});
describe('restore trusts only what this module wrote', () => {
const made = () =>
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
it('refuses a symlinked snapshot file and a snapshot file with group or world bits', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
const { id } = made();
const file = join(backups, id, 'clis.json');
chmodSync(file, 0o644);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
chmodSync(file, 0o600);
const real = join(root, 'elsewhere.json');
writeFileSync(real, '{"clis":{}}');
rmSync(file);
symlinkSync(real, file);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(/symlink/);
// nothing was written and no pre-restore copy was taken for a refused restore
expect(listConfigBackups(backups)).toHaveLength(1);
});
it('skips the group/world bit test on a filesystem that does not keep modes, but never the symlink test', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
const { id } = made();
const file = join(backups, id, 'clis.json');
chmodSync(file, 0o644); // what vfat, CIFS and WSL /mnt/d report for every file
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).not.toThrow();
const real = join(root, 'elsewhere.json');
writeFileSync(real, '{"clis":{}}');
rmSync(file);
symlinkSync(real, file);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/);
});
it('the mode probe never writes through a planted symlink or trips over a planted directory', () => {
put('settings.json', '{"a":1}');
const { id } = made();
const target = join(root, 'victim.txt');
writeFileSync(target, 'precious', { mode: 0o644 });
symlinkSync(target, join(backups, `.mode-probe-${process.pid}`));
mkdirSync(join(backups, '.mode-probe-0'));
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).not.toThrow();
expect(readFileSync(target, 'utf8')).toBe('precious');
expect(statSync(target).mode & 0o777).toBe(0o644);
});
it('compare applies the same check to a snapshot file', () => {
put('settings.json', '{"a":1}');
const { id } = made();
chmodSync(join(backups, id, 'settings.json'), 0o644);
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).toThrow(
ConfigBackupCorruptError
);
});
it('refuses a snapshot folder that others can write to', () => {
put('settings.json', '{"a":1}');
const { id } = made();
chmodSync(join(backups, id), 0o777);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
chmodSync(join(backups, id), 0o700);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
});
it('keeps the backup retention when the live settings.json was reset and has none of the four keys', () => {
put('settings.json', JSON.stringify({ displayName: 'old', configBackupKeepCount: 100, configBackupKeepDays: 0 }));
const { id } = made();
put('settings.json', '{}');
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
expect(JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'))).toEqual({
displayName: 'old',
configBackupKeepCount: 100,
configBackupKeepDays: 0,
});
});
it('latestBackupRetention skips a newer snapshot that recorded none, and a pre-restore copy records it', () => {
put('settings.json', '{"a":1}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0, keepCount: 100, keepDays: 0 });
put('settings.json', '{"a":2}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); // an older build
const origin = configBackupOrigin(data);
expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 });
const id = listConfigBackups(backups, origin).at(-1)!.id;
// A change since the newest backup, so the restore makes a pre-restore copy rather than reusing it.
put('settings.json', '{"a":3}');
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 });
expect(r.safetySnapshot).toBeTruthy();
const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!;
expect(pre.reason).toBe('pre-restore');
expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({
keepCount: 100,
keepDays: 0,
});
});
it('keeps the current folder and retention when settings.json is restored', () => {
put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 }));
const { id } = made();
put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 }));
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'));
// the folder and days follow the live file; the live file has no keep count (reset), so the backup's 5 stays
expect(after).toEqual({
displayName: 'old',
configBackupDir: '/new',
configBackupKeepCount: 5,
configBackupKeepDays: 7,
});
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* @fileoverview The global session cap can be set with `CODEMAN_MAX_SESSIONS`.
*
* Tested through `sessionCapacityState` / `sessionCapacityMessage`, the check every
* session-creating route goes through, so a regression in the env parsing OR in
* the wiring from `map-limits` to the routes (and to the derived per-user cap in
* multi-user mode) fails here.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
type Helpers = typeof import('../../src/web/route-helpers.js');
/** A sessions map of the given size, every session owned by `owner`. */
function sessionsOf(n: number, owner?: string): ReadonlyMap<string, never> {
return new Map(Array.from({ length: n }, (_, i) => [`s${i}`, { id: `s${i}`, owner } as never]));
}
/** Fresh module graph, so map-limits re-reads the environment at import. */
async function loadWithEnv(env: Record<string, string | undefined>): Promise<Helpers> {
vi.resetModules();
vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined);
vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined);
vi.stubEnv('CODEMAN_MULTIUSER', undefined);
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
return import('../../src/web/route-helpers.js');
}
describe('global session cap (CODEMAN_MAX_SESSIONS)', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it('stays at 50 when the variable is not set', async () => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({});
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
expect(sessionCapacityMessage(sessionsOf(50))).toContain('(50)');
});
it('raises the cap', async () => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: '120' });
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(119)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(120)).atGlobalCap).toBe(true);
expect(sessionCapacityMessage(sessionsOf(120))).toContain('(120)');
});
it('lowers the cap, ignoring surrounding whitespace', async () => {
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: ' 3 ' });
expect(sessionCapacityState(sessionsOf(2)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true);
});
it.each(['1000', '500', '463', '99999999999999999999'])('clamps %j to the ceiling of 462', async (value) => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
expect(sessionCapacityState(sessionsOf(461)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(462)).atGlobalCap).toBe(true);
expect(sessionCapacityMessage(sessionsOf(462))).toContain('(462)');
});
it('keeps the ceiling inside the tab-layout budget next to the saved web tabs', async () => {
vi.resetModules();
const { MAX_CONCURRENT_SESSIONS_CEILING } = await import('../../src/config/map-limits.js');
const { MAX_WEBVIEWS } = await import('../../src/config/webview-limits.js');
const { MAX_TAB_REFS } = await import('../../src/tab-layout.js');
expect(MAX_CONCURRENT_SESSIONS_CEILING + MAX_WEBVIEWS).toBeLessThanOrEqual(MAX_TAB_REFS);
});
it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('falls back to 50 for an unusable value %j', async (value) => {
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
});
it('derives the multi-user per-user cap (half the global cap) from it', async () => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({
CODEMAN_MAX_SESSIONS: '100',
CODEMAN_MULTIUSER: '1',
});
expect(sessionCapacityState(sessionsOf(49, 'alice'), 'alice').atUserCap).toBe(false);
expect(sessionCapacityState(sessionsOf(50, 'alice'), 'alice')).toEqual({ atGlobalCap: false, atUserCap: true });
expect(sessionCapacityMessage(sessionsOf(50, 'alice'), 'alice')).toContain('(50)');
});
it('leaves an explicit CODEMAN_MAX_SESSIONS_PER_USER in charge of the per-user cap', async () => {
const { sessionCapacityState } = await loadWithEnv({
CODEMAN_MAX_SESSIONS: '100',
CODEMAN_MAX_SESSIONS_PER_USER: '10',
CODEMAN_MULTIUSER: '1',
});
expect(sessionCapacityState(sessionsOf(9, 'alice'), 'alice').atUserCap).toBe(false);
expect(sessionCapacityState(sessionsOf(10, 'alice'), 'alice').atUserCap).toBe(true);
});
});
+66 -2
View File
@@ -158,10 +158,73 @@ describe('connection indicator — keystroke backlog threshold', () => {
expect(els.connectionText.textContent).toBe('WS');
});
it('annotates a genuine backlog (>4B) with a queued byte count', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
it('annotates a genuine backlog (>4B) with a queued byte count once it has persisted', () => {
const { app, els } = makeApp({
activeSessionId: 's1',
_wsState: 'connected',
queuedBytes: 40,
// The backlog has been above the threshold for well over the hold.
_backlogOverSince: Date.now() - 5000,
} as never);
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toMatch(/^WS · 40B queued$/);
app._pendingDeliveries.clear();
app._updateConnectionIndicator(); // drains: clears the hold timer
});
});
// A paste, an IME commit or a fast word is a few dozen bytes for a few milliseconds until its ACK
// lands. Showing " · NB queued" for that made the "WS" pill widen and shrink on every burst.
describe('connection indicator — a brief burst does not widen the pill', () => {
it('keeps the plain "WS" while a >4B burst is still inside the hold window', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
});
it('forgets the burst when it drains, so a later one starts a fresh hold', () => {
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).not.toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).toBeNull();
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
});
it('schedules the re-render for when the hold elapses, with no keystroke or ACK needed', () => {
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).not.toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator(); // cancels it
});
it('shows the count by itself once the hold elapses, and the timer is spent', async () => {
// A real wait, on purpose: the vm context runs on its own Date and the real setTimeout, so this
// exercises the shipped timer callback rather than a manual re-render.
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
await new Promise((r) => setTimeout(r, 1400));
expect(els.connectionText.textContent).toBe('WS · 40B queued');
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
});
it('shows the count once the same backlog has stayed up past the hold', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
(app as unknown as { _backlogOverSince: number })._backlogOverSince = Date.now() - 1500;
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS · 40B queued');
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
});
});
@@ -458,3 +521,4 @@ describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => {
expect(els.connectionText.textContent).toBe('WS');
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview Tests for the GitHub Copilot CLI resolver wrapper.
*
* Copilot is a resolver with a version probe: `copilot` is a generic binary name, so a
* resolved path is only accepted once `copilot --version` prints a
* `GitHub Copilot CLI <semver>` string (e.g. `GitHub Copilot CLI 1.0.94.`). The probe EXECUTES the candidate, which is
* exactly why it must never run under vitest — the hermeticity test below pins
* that gate with a real executable fixture that would make the test fail
* loudly if the gate were deleted again.
*/
import { chmodSync, 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 { createCopilotResolverForTest } from '../src/utils/copilot-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('GitHub Copilot CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/copilot';
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '1.0.94',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `copilot` on the service PATH (probe returns null) must not mask
// the real coding agent found by the login shell.
const impostor = '/service/bin/copilot';
const genuine = '/login-shell/bin/copilot';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.94' : null));
const resolver = createCopilotResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.94' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/copilot';
let now = 0;
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('1.0.94');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('never executes a copilot candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeCopilotVersion is ever removed again, the probe runs this script, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. (The suites must never execute
// whatever `copilot` binary the machine running them happens to carry.)
const root = mkdtempSync(join(tmpdir(), 'codeman-copilot-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'copilot');
writeFileSync(binaryPath, '#!/bin/sh\necho GitHub Copilot CLI 0.99.0.\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the
// null above comes from the gate, not from the fixture or the host.
const control = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* @fileoverview copilotConfig on the session create body: `model` and `resumeSessionId` follow a flag
* (`--model`, `--resume`), so a value that starts with `-` would be read as its own flag and get
* around the allowAll clamp. Both need a leading letter or digit.
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema } from '../src/web/schemas.js';
const parse = (copilotConfig: unknown) =>
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'copilot', copilotConfig });
describe('copilotConfig schema', () => {
it.each(['--yolo', '-n', '--allow-all-tools', '.hidden', '/etc/passwd'])('rejects model %s', (model) => {
expect(parse({ model }).success).toBe(false);
});
it.each(['--yolo', '-n', '--allow-all-tools'])('rejects resumeSessionId %s', (resumeSessionId) => {
expect(parse({ resumeSessionId }).success).toBe(false);
});
it.each(['auto', 'claude-sonnet-5.5', 'openai/gpt-5'])('accepts model %s', (model) => {
expect(parse({ model }).success).toBe(true);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { CreateSessionSchema } from '../src/web/schemas.js';
const copilot = STOCK_CLIS.find((c) => (c.id as string) === 'copilot')!;
describe('GitHub Copilot custom endpoint (BYOK)', () => {
it('injects its COPILOT_PROVIDER_* variables, with the /v1 suffix an OpenAI route needs', () => {
expect(copilot.capabilities.customModelInjection).toEqual({
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
});
});
it('keeps every injected variable privileged, so a non-granted owner cannot set them by hand', () => {
const injection = copilot.capabilities.customModelInjection;
if (injection.kind !== 'env') throw new Error('expected env injection');
for (const name of [injection.baseUrlVar, injection.apiKeyVar, ...injection.modelVars]) {
expect(copilot.capabilities.privilegedEnvKeys).toContain(name);
expect(name.startsWith('COPILOT_')).toBe(true); // reachable through the prefix allowlist
}
});
});
describe('GitHub Copilot environment and sync declarations', () => {
it('admits only its own COPILOT_ namespace, never the shared GitHub token names', () => {
// The env allowlist is one global list with no mode context, so a foreign key here would be
// settable on every session. COPILOT_GITHUB_TOKEN is already reachable through the prefix.
expect(copilot.env.allowedPrefixes).toEqual(['COPILOT_']);
expect(copilot.env.allowedKeys).toEqual([]);
});
it('declares its own MCP config so Settings does not list it as unsupported', () => {
expect(copilot.capabilities.mcpConfig).toEqual({
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
});
});
it('seeds no credentials into a Docker case', () => {
expect(copilot.overlays?.credStore).toBeUndefined();
});
});
describe('GitHub Copilot multi-user clamp: the env-var half', () => {
// COPILOT_ is an allowlisted prefix, so every variable below would otherwise pass the schema.
// Each one hands back what the allowAll clamp removed: an approval switch, code that runs with no
// approval row (a CLI build, an editor, a key command, skills, hooks), or the sign-in token sent to
// a host of the caller's choosing.
const DANGEROUS: Record<string, string> = {
COPILOT_ALLOW_ALL: 'true',
COPILOT_PLAN_THEN_AUTOPILOT: '1',
COPILOT_HOME: '/tmp/planted-copilot-home',
COPILOT_CLI_DIST_DIR: '/tmp/planted-dist',
COPILOT_PKG_CACHE_HOME: '/tmp/planted-cache',
COPILOT_CACHE_HOME: '/tmp/planted-cache',
COPILOT_EDITOR: 'sh -c planted',
COPILOT_PROVIDER_API_KEY_COMMAND: 'sh -c planted',
COPILOT_SKILLS_DIRS: '/tmp/planted-skills',
COPILOT_PROVIDERS_CONFIG: '/tmp/planted-providers.json',
COPILOT_API_URL: 'https://attacker.example',
COPILOT_GH_HOST: 'attacker.example',
COPILOT_PROVIDER_BASE_URL: 'https://attacker.example/v1',
COPILOT_PROVIDER_HEADERS: 'X-Planted: 1',
};
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('passes the env allowlist, so only the clamp stands between these and the pane', () => {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: DANGEROUS })
).not.toThrow();
});
it("strips every one of them from a non-granted owner's overrides and keeps the harmless knobs", async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
...DANGEROUS,
COPILOT_GITHUB_TOKEN: 'own-token',
COPILOT_AUTO_UPDATE: 'false',
});
expect(out).toEqual({ COPILOT_GITHUB_TOKEN: 'own-token', COPILOT_AUTO_UPDATE: 'false' });
});
});
+278
View File
@@ -0,0 +1,278 @@
/**
* @fileoverview GitHub Copilot's monthly premium-request quota for the shared plan-usage chip:
* the quota parser, the sign-in token lookup and the read-only HTTP reader.
*
* The fixture is the shape `GET /copilot_internal/user` returned for a Business seat (values rounded);
* `chat` and `completions` are `unlimited`, only `premium_interactions` is metered.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseCopilotQuotaResponse, telemetrySignature } from '../src/usage-telemetry.js';
import {
COPILOT_QUOTA_URL,
readCopilotPlanUsage,
readCopilotPlanUsageIfEnabled,
resolveCopilotToken,
} from '../src/utils/copilot-plan-usage.js';
const RESPONSE = {
copilot_plan: 'business',
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
quota_reset_date: '2026-11-01',
quota_snapshots: {
chat: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
completions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
premium_interactions: {
entitlement: 5000,
remaining: 3785,
percent_remaining: 75.7,
unlimited: false,
overage_permitted: true,
overage_count: 0,
},
},
};
describe('parseCopilotQuotaResponse', () => {
it('maps premium_interactions to a monthly window with counts and the reset instant', () => {
expect(parseCopilotQuotaResponse(RESPONSE)).toEqual({
monthly: {
usedPercentage: expect.closeTo(24.3, 5),
resetAt: Date.parse('2026-11-01T00:00:00.000Z'),
used: 1215,
limit: 5000,
},
});
});
it('ignores the unlimited chat and completions buckets entirely', () => {
const only = { quota_snapshots: { chat: RESPONSE.quota_snapshots.chat } };
expect(parseCopilotQuotaResponse(only)).toBeNull();
});
it('has nothing to meter for an unlimited premium quota', () => {
const unlimited = {
quota_snapshots: {
premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
},
};
expect(parseCopilotQuotaResponse(unlimited)).toBeNull();
});
it('derives the percent from the counts when percent_remaining is missing', () => {
const result = parseCopilotQuotaResponse({
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: 75 } },
quota_reset_date: '2026-11-01',
});
expect(result?.monthly?.usedPercentage).toBe(75);
expect(result?.monthly?.used).toBe(225);
});
it('clamps overage to 100% and keeps the real count', () => {
const result = parseCopilotQuotaResponse({
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: -40, percent_remaining: -13.3 } },
});
expect(result?.monthly?.usedPercentage).toBe(100);
expect(result?.monthly?.used).toBe(340);
});
it('rejects anything it cannot read as a quota', () => {
for (const bad of [
null,
undefined,
'x',
3,
{},
{ quota_snapshots: {} },
{ quota_snapshots: { premium_interactions: 'x' } },
]) {
expect(parseCopilotQuotaResponse(bad)).toBeNull();
}
});
it('returns nothing for a zero quota that is not unlimited', () => {
expect(
parseCopilotQuotaResponse({
quota_snapshots: { premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 0 } },
})
).toBeNull();
});
it('changes the signature when only the limit changes (a plan change mid-month)', () => {
// 100 requests used and the same reported percentage under both plans: only the entitlement differs.
const quota = (entitlement: number) =>
parseCopilotQuotaResponse({
quota_snapshots: {
premium_interactions: { entitlement, remaining: entitlement - 100, percent_remaining: 66.7 },
},
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
})!;
expect(telemetrySignature(quota(300))).not.toBe(telemetrySignature(quota(1500)));
});
it('feeds the change-detection signature without disturbing the Claude/Codex one', () => {
const copilot = parseCopilotQuotaResponse(RESPONSE)!;
expect(telemetrySignature(copilot)).not.toBe(telemetrySignature({}));
// A Claude-only payload has exactly the four-element signature it always had.
expect(JSON.parse(telemetrySignature({ fiveHour: { usedPercentage: 10, resetAt: 1 } }))).toEqual([
10,
1,
null,
null,
]);
});
});
describe('resolveCopilotToken', () => {
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
const home = (config?: string) => {
const dir = mkdtempSync(join(tmpdir(), 'copilot-token-'));
dirs.push(dir);
if (config !== undefined) {
mkdirSync(join(dir, '.copilot'));
writeFileSync(join(dir, '.copilot', 'config.json'), config);
}
return dir;
};
const CONFIG = [
'// User settings belong in settings.json.',
JSON.stringify({
lastLoggedInUser: { host: 'https://github.com', login: 'octo' },
authTokens: {
'https://github.com:octo': { token: 'file-token' },
'https://github.com:other': { token: 'other-token' },
},
}),
].join('\n');
it('prefers the environment, in the order Copilot itself reads it', async () => {
const h = home(CONFIG);
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b', COPILOT_GITHUB_TOKEN: 'a' }, h)).resolves.toBe(
'a'
);
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b' }, h)).resolves.toBe('b');
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c' }, h)).resolves.toBe('c');
});
it("falls back to the signed-in user's token in the commented config.json", async () => {
await expect(resolveCopilotToken({}, home(CONFIG))).resolves.toBe('file-token');
});
it('honours COPILOT_HOME', async () => {
const elsewhere = home();
mkdirSync(join(elsewhere, 'cp'));
writeFileSync(join(elsewhere, 'cp', 'config.json'), CONFIG.replace('file-token', 'relocated'));
await expect(resolveCopilotToken({ COPILOT_HOME: join(elsewhere, 'cp') }, home())).resolves.toBe('relocated');
});
it('reads no file token for another host, a missing file or a broken one', async () => {
const ghe = CONFIG.replace('"host":"https://github.com"', '"host":"https://ghe.example.com"');
await expect(resolveCopilotToken({}, home(ghe))).resolves.toBeNull();
await expect(resolveCopilotToken({}, home())).resolves.toBeNull();
await expect(resolveCopilotToken({}, home('{ not json'))).resolves.toBeNull();
});
});
describe('readCopilotPlanUsage', () => {
const ok = (body: unknown, status = 200) =>
vi.fn(async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
it('asks api.github.com with the bearer token, no redirects, and normalizes the answer', async () => {
const fetchFn = ok(RESPONSE);
const usage = await readCopilotPlanUsage({ token: 'tok', fetchFn });
expect(usage?.monthly?.limit).toBe(5000);
const [url, init] = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
expect(url).toBe(COPILOT_QUOTA_URL);
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok');
expect(init.redirect).toBe('error');
});
it('resolves the token itself when none is passed, and sends it in the header', async () => {
vi.stubEnv('COPILOT_GITHUB_TOKEN', 'env-token');
try {
const fetchFn = ok(RESPONSE);
expect(await readCopilotPlanUsage({ fetchFn })).not.toBeNull();
const init = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1] as {
headers: Record<string, string>;
};
expect(init.headers.Authorization).toBe('Bearer env-token');
} finally {
vi.unstubAllEnvs();
}
});
it('sends nothing when there is no token', async () => {
const fetchFn = ok(RESPONSE);
expect(await readCopilotPlanUsage({ token: null, fetchFn })).toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
});
it('answers null, never throws, on an HTTP error, a network failure or a changed shape', async () => {
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ message: 'Bad credentials' }, 401) })).toBeNull();
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ unexpected: true }) })).toBeNull();
const down = vi.fn(async () => {
throw new TypeError('fetch failed');
}) as unknown as typeof fetch;
expect(await readCopilotPlanUsage({ token: 't', fetchFn: down })).toBeNull();
});
it('never puts the token in anything it returns', async () => {
const usage = await readCopilotPlanUsage({ token: 'super-secret-token', fetchFn: ok(RESPONSE) });
expect(JSON.stringify(usage)).not.toContain('super-secret-token');
});
});
/**
* The server's 10-minute poll goes through these gates in this order: Plan Usage Limits on, then a
* sign-in token, then the CLI installed, so a host with no Copilot sign-in never runs the CLI
* resolver (whose last step is a login-shell probe) on every tick.
*/
describe('readCopilotPlanUsageIfEnabled', () => {
const gates = (over: Partial<Parameters<typeof readCopilotPlanUsageIfEnabled>[0]> = {}) => {
const fetchFn = vi.fn(
async () => new Response(JSON.stringify(RESPONSE), { status: 200 })
) as unknown as typeof fetch;
const deps = {
planUsageEnabled: vi.fn(async () => true),
resolveToken: vi.fn(async (): Promise<string | null> => 'tok'),
copilotInstalled: vi.fn(() => true),
read: vi.fn((token: string) => readCopilotPlanUsage({ token, fetchFn })),
...over,
};
return { deps, fetchFn };
};
it('sends no request, and looks for no token or CLI, while Plan Usage Limits is off', async () => {
const { deps, fetchFn } = gates({ planUsageEnabled: vi.fn(async () => false) });
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
expect(deps.read).not.toHaveBeenCalled();
expect(deps.resolveToken).not.toHaveBeenCalled();
expect(deps.copilotInstalled).not.toHaveBeenCalled();
});
it('never probes for the CLI on a host with no Copilot sign-in', async () => {
const { deps, fetchFn } = gates({ resolveToken: vi.fn(async () => null) });
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
expect(deps.copilotInstalled).not.toHaveBeenCalled();
expect(fetchFn).not.toHaveBeenCalled();
});
it('sends no request when the CLI is not installed, whatever token the environment holds', async () => {
const { deps, fetchFn } = gates({ copilotInstalled: vi.fn(() => false) });
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
});
it('reads the quota with the resolved token once every gate passes', async () => {
const { deps, fetchFn } = gates();
expect((await readCopilotPlanUsageIfEnabled(deps))?.monthly?.limit).toBe(5000);
expect(deps.read).toHaveBeenCalledWith('tok');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});
+241 -5
View File
@@ -15,7 +15,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../src/web/webview-egress.js';
vi.mock('../src/web/webview-egress.js', async () => {
@@ -31,6 +31,7 @@ function host(overrides: Partial<CustomModelHost> & Pick<CustomModelHost, 'id' |
beforeEach(() => {
fetchMock.mockReset();
clearPropsProbeCache();
});
describe('refreshAllCustomModelHosts (the periodic re-discovery sweep)', () => {
@@ -211,8 +212,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
it('prefers the REAL configured context size parsed from /running’s launch command over /props’s unreliable n_ctx', async () => {
// Confirmed live: llama-swap launched a model with --fit-ctx 16384 (the real, working
// limit — the actual server then refused a request over it), but /props reported
// n_ctx: 154112 for the same model, well over what it would really accept. /props must
// never be reached at all once the /running command parse already answered it.
// n_ctx: 154112 for the same model, well over what it would really accept. The context
// must therefore keep coming from the /running command; /props is now also probed, but
// only for `total_slots`, which the command does not state.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
@@ -235,7 +237,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
{ status: 200 }
);
}
if (url.pathname === '/props') throw new Error('must never be reached — the cmd parse already answered it');
if (url.pathname === '/props') {
return new Response(JSON.stringify({ n_ctx: 154112, total_slots: 4 }), { status: 200 });
}
throw new Error(`unexpected request: ${url.href}`);
});
@@ -243,6 +247,8 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ 'qwen3.8-27b': 16384 });
expect(updated.modelSlotCounts).toEqual({ 'qwen3.8-27b': 4 });
// /props's 154112 never becomes the context, and nothing else stores it
});
it('falls back to /props when /running has no cmd, or the cmd states no recognizable context flag', async () => {
@@ -283,13 +289,243 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
{ status: 200 }
);
}
throw new Error(`unexpected request: ${url.href}`); // /props must never be reached
if (url.pathname === '/props') return new Response('not found', { status: 404 }); // slot probe only
throw new Error(`unexpected request: ${url.href}`);
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ a: 8192 });
expect(updated.modelSlotCounts).toBeUndefined();
});
});
describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits its context across --parallel slots)', () => {
/** One loaded model whose launch command is `cmd`; `/props` behaviour is supplied by the test. */
function serve(cmd: string, props: () => Response) {
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(JSON.stringify({ running: [{ model: 'm', state: 'ready', cmd }] }), { status: 200 });
}
if (url.pathname === '/props') return props();
throw new Error(`unexpected request: ${url.href}`);
});
}
const propsMustNotBeReached = () => {
throw new Error('/props must not be probed: the launch command already answered both questions');
};
it.each([
['--parallel 1', 1],
['-np 2', 2],
['--parallel=3', 3],
['--fit-ctx 65536 --parallel 1 --host 0.0.0.0', 1],
])('reads an explicit %s from the launch command without probing /props', async (flags, slots) => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(`llama-server -m /models/m.gguf --ctx-size 65536 ${flags}`, propsMustNotBeReached);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: slots });
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('asks /props for total_slots when the command states no --parallel (llama-server picks the count itself)', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536',
() => new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 4 });
// the context still comes from the command's floor, never from /props's n_ctx
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('probes /props once per launch command: a repeat sweep reuses the answer, a changed command probes again', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
let probes = 0;
const answer = () => (probes++, new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 }));
serve('llama-server -m /models/m.gguf --fit-ctx 65536', answer);
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(1); // a /props call refreshes the model's last-use time in llama-swap, so not every sweep
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
serve('llama-server -m /models/m.gguf --fit-ctx 65536 --flash-attn on', answer);
await refreshAllCustomModelHosts();
expect(probes).toBe(2);
});
it('does not divide a /props context a second time when --parallel is explicit and the command states no context', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
// with an explicit count llama.cpp reports n_ctx per slot: 65,536 each is already what one request gets
serve(
'llama-server -m /models/m.gguf --parallel 4',
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ m: 65536 });
expect(updated.modelSlotCounts).toEqual({ m: 1 }); // already per slot, so one request gets all of it
});
it('a later round with an explicit --parallel replaces the slot count an earlier round learned', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
// round 1: auto slots, the server chose 4 sharing 131,072 tokens
serve(
'llama-server -m /models/m.gguf',
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
// round 2: the user followed the dialog and added --parallel 1
serve(
'llama-server -m /models/m.gguf --parallel 1',
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 1 }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 1 });
// round 3: --parallel 2 with a per-slot /props context must not bring a pool back
serve(
'llama-server -m /models/m.gguf --parallel 2',
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 2 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 1 });
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('remembers a /props that answers non-2xx per launch command, but retries a network failure', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
let probes = 0;
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => (probes++, new Response('nope', { status: 404 })));
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(1); // no probe every sweep: a /props call keeps a model with a ttl loaded
clearPropsProbeCache();
probes = 0;
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => {
probes++;
throw new TypeError('fetch failed');
});
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(2); // a network error says nothing about the server, so it is tried again
});
it('treats -np -1 (llama.cpp auto) as unstated and falls back to /props', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536 -np -1',
() => new Response(JSON.stringify({ total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 4 });
});
it('a failed or slot-less /props leaves no slot count rather than failing the sweep or guessing one', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536',
() => new Response(JSON.stringify({ n_ctx: 8192 }), { status: 200 })
);
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toBeUndefined();
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('keeps a previously-learned slot count for a model that is no longer loaded, and drops it once the model disappears', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({
id: 'ep',
baseUrl: 'http://localhost:8080',
models: ['a', 'b'],
modelContextLengths: { a: 65536, b: 65536 },
modelSlotCounts: { a: 4, b: 2 },
}),
]);
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'a' }] }), { status: 200 }));
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ a: 4 });
});
it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => {
// The false-warning scenario, measured on a real llama-swap: round 1 has the model
// loaded under `--ctx-size 262144 --parallel 4`: a TOTAL window shared across slots,
// correctly stored with its count. Round 2 finds it unloaded and takes the LISTED
// `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4
// survived, the floor check would divide the per-slot figure a second time
// (65536/4 = 16384) and warn about a model that serves 65536 to every request.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }],
}),
{ status: 200 }
);
}
throw new Error(`unexpected request: ${url.href}`); // the command answers both questions
});
await refreshAllCustomModelHosts();
let [round1] = await readCustomModelHosts(dir);
expect(round1.modelContextLengths).toEqual({ m: 262144 });
expect(round1.modelSlotCounts).toEqual({ m: 4 });
// Round 2: unloaded, no /running or /props probe in order, listing its per-slot figure.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'unloaded' }, context_length: 65536 }] }), {
status: 200,
})
);
await refreshAllCustomModelHosts();
const [round2] = await readCustomModelHosts(dir);
expect(round2.modelContextLengths).toEqual({ m: 65536 });
// 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so
// the floor check reads 65536/request: exactly what the server gives a single request.
expect(round2.modelSlotCounts).toEqual({ m: 1 });
});
});
+10 -6
View File
@@ -53,9 +53,12 @@ describe('applyCustomModelInjection: context length', () => {
expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
});
it('deepseek: has no contextLengthVar declared, so a passed-in length is a no-op', () => {
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384);
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
it('deepseek: passes the known context through its isolated model catalogue', () => {
const sessionId = 'sess-deepseek-context';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768);
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY', 'DSH_HOME']);
expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768');
});
});
@@ -103,11 +106,12 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId));
});
it('deepseek: no configDirVar declared, so no config dir is created at all', () => {
it('deepseek: creates an isolated profile patch', () => {
const sessionId = 'sess-cfgdir-deepseek';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId);
expect(applied?.configDir).toBeUndefined();
expect(existsSync(customModelConfigDir(sessionId))).toBe(false);
expect(applied?.configDir).toBe(customModelConfigDir(sessionId));
expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true);
});
});
+10 -10
View File
@@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => {
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
});
it('deepseek: DEEPSEEK_BASE_URL already carries the /v1 suffix dsh itself never adds, reaching the mock at the real path dsh requests', async () => {
// Confirmed by reading dsh's own bundled source: it fetches
// `${DEEPSEEK_BASE_URL}/chat/completions` verbatim, no /v1 insertion of its own — so
// this call (unlike gemini's above) passes DEEPSEEK_BASE_URL to callOpenAiCompat
// UNMODIFIED, exactly mirroring what the real harness does, rather than the test
// helping it along.
it('deepseek: the session patch carries the /v1 base URL and exact model, reaching the mock at the real path', async () => {
// dsh fetches `${baseURL}/chat/completions` verbatim, so the patch's baseURL already
// carries /v1 and is passed to callOpenAiCompat unmodified.
const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3');
if (injection.kind !== 'env') throw new Error('unreachable');
expect(Object.keys(injection.envOverrides).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
expect(injection.envOverrides.DEEPSEEK_BASE_URL).toBe(`${mock.baseUrl}/v1`);
if (injection.kind !== 'configDir') throw new Error('unreachable');
expect(injection.dirEnvVar).toBe('DSH_HOME');
expect(Object.keys(injection.extraEnv ?? {})).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
const provider = JSON.parse(injection.files[0].content)[0].config.providers['codeman-custom'];
expect(provider.baseURL).toBe(`${mock.baseUrl}/v1`);
expect(provider.models[0].id).toBe('qwen3');
await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3');
await callOpenAiCompat(provider.baseURL, injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY, 'qwen3');
expect(mock.requests[0].path).toBe('/v1/chat/completions');
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
+72 -17
View File
@@ -8,6 +8,7 @@
*/
import { describe, it, expect } from 'vitest';
import { parse as parseToml } from 'smol-toml';
import { getCli } from '../src/config/cli-registry/index.js';
import {
buildCustomModelInjection,
@@ -112,6 +113,31 @@ describe('buildCustomModelInjection', () => {
expect(parsed.provider.custom.models.qwen3).toEqual({});
});
it('opencode: threads a discovered context length into the model limit as the full window (issue #749)', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 16384);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
// `limit.context` IS the full window to opencode (it reserves for the reply itself),
// so the discovered total passes through; `output` only caps the reply at a quarter.
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 16384, output: 4096 } });
});
it('opencode: caps the response headroom reservation at 8192 for big windows', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 262144);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 262144, output: 8192 } });
});
it('opencode: never invents a limit for an unknown or non-finite context length (issue #749 regression)', () => {
for (const contextLength of [undefined, NaN]) {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', contextLength);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
expect(parsed.provider.custom.models.qwen3).toEqual({});
}
});
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
expect(result.kind).toBe('configDir');
@@ -133,6 +159,30 @@ describe('buildCustomModelInjection', () => {
expect(result.files[0].content).toContain('model = "weird\\"model"');
});
it('codex: writes the discovered window as TOP-LEVEL model_context_window with a 75% compact limit (issue #750)', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', 16384);
if (result.kind !== 'configDir') throw new Error('unreachable');
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
// Top-level is the shape codex reads: parked under [model_providers.custom] (its
// provider table) the same keys would be silently ignored by codex itself.
expect(doc.model_context_window).toBe(16384);
expect(doc.model_auto_compact_token_limit).toBe(12288); // 75% of the window, floored
const provider = (doc.model_providers as Record<string, Record<string, unknown>>).custom;
expect(provider.model_context_window).toBeUndefined();
expect(provider.model_auto_compact_token_limit).toBeUndefined();
});
it('codex: writes neither context line for an unknown, non-finite or zero context length', () => {
for (const contextLength of [undefined, NaN, 0]) {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', contextLength);
if (result.kind !== 'configDir') throw new Error('unreachable');
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
expect(doc.model_context_window, String(contextLength)).toBeUndefined();
expect(doc.model_auto_compact_token_limit, String(contextLength)).toBeUndefined();
expect(doc.model).toBe('qwen3'); // the rest of the file is still written
}
});
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
@@ -179,29 +229,32 @@ describe('buildCustomModelInjection', () => {
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
});
it('deepseek: env kind sets base URL (with a /v1 suffix appended) and key, no model var', () => {
// appendV1Suffix is REQUIRED here, not cosmetic: confirmed by reading dsh's own
// bundled source (@deepseek-ai/dsh-llm-deepseek) that it builds the request URL as
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own, while
// llama-swap/llama.cpp only serves "/v1/chat/completions" — without this, every
// request 404s (confirmed live; this is the fix for the originally-reported
// "dsh: HTTP_404: DeepSeek API error (HTTP 404)").
it('deepseek: isolates the selected model route and passes its key by reference', () => {
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides).toEqual({
DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080/v1',
DEEPSEEK_API_KEY: 'my-key',
});
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
expect(result.dirEnvVar).toBe('DSH_HOME');
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
expect(result.files[0].content).not.toContain('my-key');
const patch = JSON.parse(result.files[0].content);
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY:
// dsh's llm-deepseek/web-search rows read that too and would leak this key to
// api.deepseek.com on an in-session /model switch back to the official route.
expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe(
'CODEMAN_CUSTOM_MODEL_API_KEY'
);
expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY');
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3');
});
it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => {
it('deepseek: preserves an existing version suffix', () => {
const result = buildCustomModelInjection(
entryOrThrow('deepseek'),
{ ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' },
{ ...endpoint, baseUrl: endpoint.baseUrl + '/v1' },
'qwen3'
);
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1');
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
expect(result.files[0].content).toContain('http://192.168.1.50:8080/v1');
expect(result.files[0].content).not.toContain('/v1/v1');
});
it('antigravity: unsupported', () => {
@@ -232,7 +285,9 @@ describe('launchModel (the model launch param that selects the injected provider
});
it('CLIs whose config selects the model on its own declare none', () => {
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
// codex now declares launchModel (selected-model launch override) and is covered by
// its own restart coverage, not here.
for (const id of ['claude', 'opencode', 'gemini', 'deepseek']) {
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
if (result.kind === 'unsupported') throw new Error('unreachable');
expect(result.launchModel, id).toBeUndefined();
@@ -0,0 +1,91 @@
/**
* @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME
* context-length resolution the routes use: the model's discovered figure, falling back to
* the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the
* shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone, so a session
* on a model that only the endpoint default covers came back from a Codeman restart with
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone,
* re-arming exactly the window overflow the var exists to prevent.
*
* `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint
* store and the Session it is handed), so it is driven straight off the prototype with an
* empty receiver: `restoreMuxSessions()` itself cannot be reached under vitest, the same
* constraint recorded in test/session-model-recovery.test.ts.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir, type AppliedCustomModel } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise<AppliedCustomModel | undefined>;
/** The real method, with an empty receiver, see the file header for why that is sound. */
function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) {
const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv;
expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function');
return fn.call({}, session, saved);
}
function endpoint(overrides: Partial<CustomModelHost> = {}): CustomModelHost {
return {
id: 'ep',
label: 'Ep',
baseUrl: 'http://localhost:8080',
apiKey: 'recovery-probe-key',
models: ['qwen3'],
...overrides,
};
}
describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => {
const configs: string[] = [];
afterEach(() => {
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
});
it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768');
});
it('a discovered per-model figure still wins over the endpoint default', async () => {
await writeCustomModelHosts(getDataDir(), [
endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }),
]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072');
});
it('states nothing rather than guessing when neither source knows a window', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
});
it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe(
undefined
);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Boot recovery re-derives a saved custom-model selection through
* `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe
* applied, not from the saved record. A selection persisted while deepseek still used
* the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/
* `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
* that clearing could never delete.
*
* Real-home resolution is NOT recovered from the session's env overrides any more:
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
*
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
* everything from the endpoint store and the Session it is handed), so it is driven off
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
/** The real pair, on a receiver that only carries the rebuild helper, see the header. */
async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
const proto = WebServer.prototype as unknown as {
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>;
_restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise<void>;
};
expect(typeof proto._restoreCustomModel, '_restoreCustomModel vanished from WebServer.prototype').toBe('function');
await proto._restoreCustomModel.call({ _rebuildCustomModelEnv: proto._rebuildCustomModelEnv }, session, saved);
}
function endpoint(): CustomModelHost {
return {
id: 'ep',
label: 'Ep',
baseUrl: 'http://localhost:8080',
apiKey: 'recovery-probe-key',
models: ['qwen3'],
};
}
describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', () => {
const configs: string[] = [];
afterEach(() => {
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
vi.unstubAllEnvs();
});
it('carries the configDir and env-key union for a selection saved by the old env recipe', async () => {
// Saved exactly as 1.31.0-1.47.0 persisted a deepseek selection: the env recipe's two
// keys, no configDir. The rebuilt record must carry the per-session config dir the
// CURRENT recipe wrote (so clearing deletes it) while KEEPING the saved keys, which
// the tmux session may still carry `setenv`s for (the union is what a clear unsets).
const dshHome = join(homedir(), 'codeman-recovery-dsh-home');
mkdirSync(join(dshHome, 'profiles'), { recursive: true });
vi.stubEnv('DSH_HOME', dshHome);
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
label: 'Ep',
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
});
const persisted = session.getCustomModelForPersist();
expect(persisted?.configDir).toBe(customModelConfigDir(session.id));
expect(persisted?.launchModel).toBeUndefined();
expect(new Set(persisted?.envKeys)).toEqual(
new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'])
);
rmSync(dshHome, { recursive: true, force: true });
});
it('restores an env-shaped record unchanged when the endpoint is gone, so a clear still works', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
const saved: CustomModelBookkeeping = {
endpointId: 'gone',
modelId: 'qwen3',
label: 'Gone',
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
};
await restoreCustomModel(session, saved);
expect(session.getCustomModelForPersist()).toEqual(saved);
});
});
+455 -9
View File
@@ -20,6 +20,10 @@ import { describe, expect, it, vi } from 'vitest';
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
// Only for the [hidden] CSS guard below: the filter toggles the `hidden` property, and
// `.run-mode-option`'s own `display: flex` would silently defeat it without the
// `!important` re-assertion in styles.css.
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
// Only for the real _showCenterStatus DOM tests below (`bootAppWithRealCenterStatus`) —
// every other test in this file stubs _showCenterStatus itself and has no need of it.
const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8');
@@ -40,6 +44,8 @@ function bootApp(
cliAvailable?: (id: string) => boolean;
activeCase?: { location?: string } | null;
settingsEnabled?: boolean;
/** Live sessions for the "in use by a session" fallback, in `this.sessions` Map shape. */
sessions?: Array<Record<string, unknown>>;
} = {}
) {
const dom = new JSDOM(
@@ -55,6 +61,10 @@ function bootApp(
<div class="modal" id="customModelPickModal">
<h3 id="customModelPickTitle"></h3>
<p id="customModelPickHint"></p>
<input type="search" id="customModelPickSearch" aria-label="Search models"
oninput="app.filterCustomModelPick(this.value)">
<p id="customModelPickNoMatches" role="status" hidden>No models match</p>
<p id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p>
<div id="customModelPickList"></div>
</div>
<div class="modal" id="customModelSwapConfirmModal">
@@ -81,6 +91,10 @@ function bootApp(
app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }];
app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true });
app.isCliAvailable = options.cliAvailable ?? (() => true);
// The real app keeps live sessions in a Map keyed by id (app.js), which is what
// _getCustomModelInUseBySession walks; empty by default so a test that says nothing
// about sessions sees no promotion, exactly like a box with none running.
app.sessions = new Map((options.sessions || []).map((s, i) => [String(i), s]));
app.showToast = () => {};
// Real implementation lives in panels-ui.js, not evaluated into this harness (only
// constants.js + session-ui.js are — see below) — a no-op default handle matching its
@@ -315,6 +329,27 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
expect(buttons[0].textContent).not.toContain('Currently loaded');
});
it('treats a remembered "last used" id the host no longer lists as nothing promoted: no tag, the line shows', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
// The remembered model was deleted from this endpoint since the last launch.
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'gone-model');
await app.selectCustomModelEntry('claude', 'llama-box');
const list = win.document.getElementById('customModelPickList')!;
expect(list.textContent).not.toContain('Last used');
// Without a promoted row the probe really did answer "nothing is known", so the
// explanation line must show rather than leave two unmarked rows unexplained.
expect(win.document.getElementById('customModelPickUnknown')!.hidden).toBe(false);
});
it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
@@ -532,9 +567,349 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
await app.selectCustomModelEntry('claude', 'llama-box');
expect(toastMessage).toMatch(/no models discovered/i);
});
it('resets and focuses the search box every time the picker opens', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
// The caret lands in the box, so the first keystroke filters with no click.
expect(win.document.activeElement).toBe(search);
// A stale query from a previous open must not survive into the next one.
search.value = 'zzz';
search.dispatchEvent(new win.Event('input'));
app.closeCustomModelPickModal();
await app.selectCustomModelEntry('claude', 'llama-box');
expect(search.value).toBe('');
expect(win.document.activeElement).toBe(search);
expect(win.document.getElementById('customModelPickNoMatches')!.hasAttribute('hidden')).toBe(true);
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
(b) => !b.hasAttribute('hidden')
);
expect(visible).toHaveLength(2);
});
it('leaves the search box unfocused on touch devices so the OS keyboard stays off the list', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
// The guard reads the global the browser defines (mobile-detection.js).
(win as unknown as { MobileDetection: { isTouchDevice: () => boolean } }).MobileDetection = {
isTouchDevice: () => true,
};
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
expect(win.document.activeElement).not.toBe(search);
// Only the focus is skipped: the rows still render, unfiltered and functional.
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
(b) => !b.hasAttribute('hidden')
);
expect(visible).toHaveLength(2);
});
it('filters rows case-insensitively on the model id, keeping the surviving row fully functional', async () => {
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://x',
models: ['qwen3', 'llama3'],
defaultModelId: 'qwen3',
},
],
});
let launched: unknown[] | null = null;
app.runCustomModelEntry = async (...args: unknown[]) => {
launched = args;
};
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'QWEN';
search.dispatchEvent(new win.Event('input'));
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons.filter((b) => b.hasAttribute('hidden')).map((b) => b.textContent!.trim())).toEqual(['llama3']);
const [visible] = buttons.filter((b) => !b.hasAttribute('hidden'));
// The surviving row keeps its Default badge and its real, callable click handler.
expect(visible.textContent).toContain('qwen3');
expect(visible.textContent).toContain('Default');
(visible as unknown as HTMLButtonElement & { onclick: (e: unknown) => void }).onclick(
new (win as any).Event('click')
);
expect(launched).toEqual(['claude', 'llama-box', 'qwen3']);
});
it('shows the no-matches line only while a query matches nothing, and clears it with the query', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
const isHidden = () =>
win.document.getElementById('customModelPickList')!.querySelectorAll('button[hidden]').length;
// Whitespace is not a query: everything stays visible, no message.
search.value = ' ';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(true);
expect(isHidden()).toBe(0);
search.value = 'ghost';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(false);
expect(isHidden()).toBe(2);
search.value = '';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(true);
expect(isHidden()).toBe(0);
});
it('keeps an HTML-significant model id inert in the picker rows too, and filterable by its raw text', async () => {
// Same vector as the Run-menu row above, now via data-model-id: escapeHtml covers
// the double quote, and the filter reads the decoded raw id back off dataset.
const dangerousModel = '"><img src=x onerror=alert(1)>';
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', dangerousModel] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const list = win.document.getElementById('customModelPickList')!;
expect(list.querySelector('img')).toBeNull();
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'src=x';
search.dispatchEvent(new win.Event('input'));
const visible = list.querySelectorAll('button:not([hidden])');
expect(visible).toHaveLength(1);
expect(visible[0].getAttribute('data-model-id')).toBe(dangerousModel);
});
it('re-asserts display:none !important for hidden picker rows so the filter cannot be silently defeated', () => {
// `.run-mode-option` carries its own `display: flex` (styles.css, restated in
// mobile.css), which outranks the UA `[hidden]` rule: without this scoped
// !important re-assertion, toggling `hidden` would change nothing on screen.
const rule = STYLES_CSS.match(/#customModelPickModal \.run-mode-option\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'picker rows need a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
});
it('pins "No models match" hidden with an empty query and shown only when a query hides every row, in CSS too', async () => {
// The visible symptom: the line carries `.form-hint`, whose `display: block`
// outranks the UA `[hidden]` rule, so `hidden` alone showed "No models match"
// while every model was listed with an empty search box. The attribute half
// was always right (session-ui.js's `filterCustomModelPick`); the CSS half
// needed the same `[hidden] { display: none !important }` re-assertion the
// rows above carry. This test pins both states AND the CSS that makes them
// actually render.
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-no-matches\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'the no-matches line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
// Empty query, models listed: the attribute must be set, and the CSS rule
// above is what makes that attribute actually hide the line.
expect(noMatches.hasAttribute('hidden')).toBe(true);
// A non-empty query that hides every row: shown again.
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'ghost';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(false);
});
});
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
/**
* The reported bug: a gateway such as Bifrost (40 models, alphabetical, no llama-swap
* `/running` route of its own) showed "Choose a model" with nothing marked, so the user
* could not tell which model the endpoint was actually serving, while the same endpoint
* reached from the Codex CLI appeared to "default to alpha order" for every other CLI.
* The endpoint genuinely cannot answer, so the app falls back to what it does know: a
* live session it has itself pointed at that endpoint. And it says so plainly when it
* knows neither.
*/
describe('Custom Model Endpoint Profiles: gateways without /running fall back to a live session', () => {
const GATEWAY = {
id: 'bifrost',
label: 'bifrost',
baseUrl: 'https://bifrost.example',
models: ['llama-swap/alpha-3b', 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs', 'llama-swap/zeta-70b'],
};
/** The gateway's exact shape: reachable, OpenAI-compatible, and not llama-swap. */
function gatewayApp(sessions: Array<Record<string, unknown>>) {
const booted = bootApp({ hosts: [GATEWAY], sessions });
const origApiJson = booted.app._apiJson;
booted.app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/bifrost/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
return booted;
}
const live = (modelId: string, overrides: Record<string, unknown> = {}) => ({
name: 'w9-Gateway',
status: 'busy',
pid: 4242,
createdAt: 1_700_000_000_000,
customModel: { endpointId: 'bifrost', modelId, label: 'bifrost' },
...overrides,
});
it('marks the model a live session is running, at the top, for EVERY CLI, not just claude', async () => {
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
for (const mode of ['opencode', 'deepseek', 'codex', 'claude']) {
const { win, app } = gatewayApp([live(inUse)]);
await app._openCustomModelPickModal(mode, GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons).toHaveLength(3);
expect(buttons[0].textContent, mode).toContain(inUse);
expect(buttons[0].textContent, mode).toContain('In use by a session');
// The other rows keep discovery order and carry no tag of their own.
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
// Nothing was fabricated about the endpoint itself.
expect(win.document.getElementById('customModelPickList')!.textContent).not.toContain('Currently loaded');
expect(win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
}
});
it('a live session outranks this device’s "last used" memory, and both rank above nothing', async () => {
const { win, app } = gatewayApp([live('llama-swap/zeta-70b')]);
win.localStorage.setItem('codeman:customModelLastUsed:opencode:bifrost', 'llama-swap/alpha-3b');
await app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons[0].textContent).toContain('In use by a session');
expect(buttons[0].textContent).not.toContain('Last used');
// The remembered pick is still listed, just not promoted or double-tagged.
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
});
it('a stopped session, an exited process, an exited pane, another endpoint or an undiscovered model mark nothing', async () => {
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
const cases: Array<[string, Record<string, unknown>]> = [
['stopped', { status: 'stopped' }],
['process exited (pid null)', { pid: null }],
['pane exited', { paneExit: { at: 1, status: 0 } }],
['other endpoint', { customModel: { endpointId: 'other', modelId: inUse } }],
['no custom model', { customModel: undefined }],
['undiscovered model', { customModel: { endpointId: 'bifrost', modelId: 'llama-swap/retired-model' } }],
];
for (const [label, overrides] of cases) {
const { win, app } = gatewayApp([live(inUse, overrides)]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const list = win.document.getElementById('customModelPickList')!;
expect(list.textContent, label).not.toContain('In use by a session');
expect(list.textContent, label).not.toContain('Last used');
// Discovery order untouched: nothing got dragged to the top.
expect(list.querySelectorAll('button')![0].textContent).toContain('llama-swap/alpha-3b');
}
});
it('the most recently launched live session wins when two share an endpoint', async () => {
const { win, app } = gatewayApp([
live('llama-swap/alpha-3b', { createdAt: 1 }),
live('llama-swap/zeta-70b', { createdAt: 2 }),
]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons.filter((b) => b.textContent!.includes('In use by a session'))).toHaveLength(1);
});
it('says so plainly when nothing is known (a down endpoint answers the very same shape)', async () => {
const { win, app } = gatewayApp([]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const unknown = win.document.getElementById('customModelPickUnknown')!;
expect(unknown.hasAttribute('hidden')).toBe(false);
expect(unknown.textContent).toContain('did not report which model it has loaded');
// What REALLY arrives here, not a fiction: `getLlamaSwapStatus` (custom-model-routes.ts)
// turns EVERY upstream failure into `isLlamaSwap: false` (a gateway with no
// `/running` route, a closed port, an unresolvable host, a 401), so this stub is
// also exactly what a DOWN endpoint produces, which is why the line's wording says
// only "did not report" and never why.
// The same class-of-bug trap as the no-matches line: `.form-hint`'s `display: block`
// outranks the UA `[hidden]` rule, so the attribute needs the CSS re-assertion.
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-unknown\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'the unknown-endpoint line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
});
it('hides that line whenever a row IS marked, and for a probe that never got any answer', async () => {
// Marked by a live session: nothing to explain.
const marked = gatewayApp([live('llama-swap/alpha-3b')]);
await marked.app._openCustomModelPickModal('opencode', GATEWAY);
expect(marked.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
// `null` (no answer at all) only happens when Codeman's OWN request to the route
// failed or the client-side bound won the race. The route itself never answers this
// way for an unreachable endpoint: every fetch failure arrives as `isLlamaSwap:
// false`, pinned by the test above. The box may merely be asleep, so stay silent.
const asleep = bootApp({ hosts: [GATEWAY] });
asleep.app._apiJson = async (path: string) => (path.includes('running-status') ? null : []);
await asleep.app._openCustomModelPickModal('opencode', GATEWAY);
expect(asleep.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
});
it('llama-swap’s own answer still wins over a session pin, and keeps its own tag', async () => {
const booted = bootApp({
hosts: [GATEWAY],
sessions: [live('llama-swap/alpha-3b')],
});
const origApiJson = booted.app._apiJson;
booted.app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/bifrost/running-status') {
return { isLlamaSwap: true, running: [{ model: 'llama-swap/zeta-70b', state: 'ready' }] };
}
return origApiJson(path);
};
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...booted.win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons[0].textContent).toContain('Currently loaded');
expect(buttons[0].textContent).not.toContain('In use by a session');
// The session's model is an ordinary row again: one promotion, not two.
expect(buttons.filter((b) => b.querySelector('.set-scope'))).toHaveLength(1);
});
it('never trusts displayModel: a session whose terminal merely printed a model marks nothing', async () => {
const booted = gatewayApp([
{ name: 'w9-Other', status: 'busy', createdAt: 1, displayModel: { model: 'llama-swap/zeta-70b', source: 'screen' } },
]);
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
expect(booted.win.document.getElementById('customModelPickList')!.textContent).not.toContain('In use by a session');
});
});
describe('Custom Model Endpoint Profiles: _getCustomModelRunningStatus is client-side bounded', () => {
// `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as
// `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this
// code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers()
@@ -549,26 +924,31 @@ describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is clie
// endpoint that is asleep or firewalled, distinct from one that answers an error.
app._apiJson = () => new Promise(() => {});
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBeNull();
// `null` means "no answer at all" (Codeman's own request failed or the client-side
// bound won): the picker keeps its "Nothing is marked" line hidden for exactly this
// case (pinned in the gateway describe above). An endpoint that is merely down is
// NOT here: the route turns every fetch failure into `isLlamaSwap: false`.
expect(status).toBeNull();
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBeNull();
});
it('an endpoint that answers well within the bound is unaffected by it', async () => {
const { app } = bootApp({});
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBe('qwen3');
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBe('qwen3');
});
it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => {
const { app } = bootApp({});
app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10));
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(result).toBeNull();
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(status).toBeNull();
// Give the loser of the race a turn to actually reject and hit its own .catch —
// an unswallowed rejection here would surface as an "Unhandled Errors" failure
// for the whole test file, not a failed assertion in this test.
@@ -745,7 +1125,7 @@ describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
});
it('routes through run() itself, so the Run in-flight lock actually engages', async () => {
// CLAUDE.md, Run launch synchronization: the lock exists so a double click
// src/web/CLAUDE.md, Run launch synchronization: the lock exists so a double click
// cannot create duplicate sessions. A hardcoded dispatch table bypassing
// run() would never set _runInFlight, which is what this pins.
const { app } = bootApp({});
@@ -1281,7 +1661,7 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(confirmArgs).toEqual(['qwen3', 16384, 40000]);
expect(confirmArgs).toEqual(['qwen3', 16384, 40000, undefined]);
expect(applyBodies).toEqual([
{ endpointId: 'llama-box', modelId: 'qwen3' },
{ endpointId: 'llama-box', modelId: 'qwen3', confirmedContext: true },
@@ -1289,6 +1669,29 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
});
it('forwards the slot count from the apply response into the modal', async () => {
const { app, applyBodies } = launchHarness([
{
requiresContextWarning: true,
modelId: 'qwen3',
contextLength: 131072,
minSafeContextTokens: 40000,
slotCount: 4,
},
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
]);
let confirmArgs: unknown[] | undefined;
app._confirmContextWarning = async (...args: unknown[]) => {
confirmArgs = args;
return true;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(confirmArgs).toEqual(['qwen3', 131072, 40000, 4]);
expect(applyBodies).toHaveLength(2);
});
it('declining the in-app context-warning modal keeps the native backend, never re-sends the apply, and must NEVER record this model as "last used" — it cannot work with this CLI at all', async () => {
const { win, app, applyBodies } = launchHarness([
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
@@ -1361,6 +1764,49 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(false);
});
it('explains shared parallel slots, and the --parallel 1 fix, when the context is enough only as a whole', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3.8-27b-ud-q4_k_xl', 131072, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
expect(message).toContain('4 parallel');
expect(message).toContain('131,072');
expect(message).toContain('32,768'); // per slot
expect(message).toContain('--parallel 1');
expect(message).toContain('Context size has been exceeded');
// this is not the "first message fails outright" case, so it must not claim that
expect(message).not.toMatch(/first message will fail/i);
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(true);
app._resolveContextWarningConfirm(false);
});
it('says the context is split across the slots, about N each, from the discovered context alone', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 65536, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('65,536 tokens of context are split across 4 parallel server slots, about 16,384 each');
app._resolveContextWarningConfirm(false);
});
it('keeps the too-small-context message for a small model and adds the slot advice when slots are known', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 16384, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('16,384');
expect(message).toMatch(/first message will fail/i);
expect(message).toContain('4 parallel slots');
expect(message).toContain('--parallel 1');
app._resolveContextWarningConfirm(false);
});
it('does not mention slots when there is one (or none known)', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 16384, 40000, 1);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).not.toMatch(/parallel/i);
app._resolveContextWarningConfirm(false);
});
it('describes an unknown context length without printing a bogus number', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', undefined, 40000);
@@ -0,0 +1,68 @@
/**
* @fileoverview `guardSessionConfigDirHome` probes a session-supplied configDir home before
* the apply runs synchronous fs calls on it (the #516 rule): a home that does not answer is
* hidden from the apply, so it links from the server's own home instead and says so.
*
* The probe is mocked: a mount that never answers is the case under test, and a real one
* cannot be made here.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn() };
});
import { getCli } from '../src/config/cli-registry/registry.js';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
const probe = vi.mocked(probePathKind);
const deepseek = () => getCli('deepseek')!;
const env =
(values: Record<string, string>) =>
(name: string): string | undefined =>
values[name];
describe('guardSessionConfigDirHome', () => {
afterEach(() => {
probe.mockReset();
vi.restoreAllMocks();
});
it('hides only the home when it does not answer, and warns that the server home is used', async () => {
probe.mockResolvedValue('unknown');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const lookup = await guardSessionConfigDirHome(deepseek(), env({ DSH_HOME: '/mnt/dead/dsh', OTHER: 'kept' }));
expect(lookup('DSH_HOME')).toBeUndefined();
expect(lookup('OTHER')).toBe('kept');
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', { pastCap: true });
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('/mnt/dead/dsh');
});
it('passes a home that answers straight through', async () => {
probe.mockResolvedValue('directory');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const base = env({ DSH_HOME: '/home/u/.dsh' });
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
expect(warn).not.toHaveBeenCalled();
});
it('never probes a relative home: the apply refuses to join one', async () => {
const base = env({ DSH_HOME: 'relative/dsh' });
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
expect(probe).not.toHaveBeenCalled();
});
it('never probes for a CLI with no linkFromRealHome recipe', async () => {
const base = env({ CODEX_HOME: '/mnt/dead/codex' });
expect(await guardSessionConfigDirHome(getCli('codex')!, base)).toBe(base);
expect(probe).not.toHaveBeenCalled();
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* @fileoverview Unit tests for the parallel-slot awareness of the custom-model context floor
* (`perSlotContext` / `exceedsSafeContextFloor`, src/web/routes/custom-model-routes.ts).
*
* llama-server splits its context across `--parallel` slots (by default they share one
* unified KV pool) and Claude Code sends concurrent requests, each carrying its own ~40k
* tokens of system prompt and tools. Measured against a real llama-swap server: with 4 slots
* sharing ~131k tokens, four simultaneous 40k-token requests all failed with HTTP 500
* "Context size has been exceeded"; the same requests against one slot all succeeded.
*
* Port: N/A (pure functions, no server).
*/
import { describe, it, expect } from 'vitest';
import {
CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
exceedsSafeContextFloor,
perSlotContext,
} from '../src/web/routes/custom-model-routes.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const withContextVar = {
capabilities: { customModelInjection: { kind: 'env', contextLengthVar: 'CLAUDE_CODE_MAX_CONTEXT_TOKENS' } },
} as unknown as Pick<CliEntry, 'capabilities'>;
const withoutContextVar = {
capabilities: { customModelInjection: { kind: 'env' } },
} as unknown as Pick<CliEntry, 'capabilities'>;
describe('perSlotContext', () => {
it('divides the context across the slots', () => {
expect(perSlotContext(131072, 4)).toBe(32768);
expect(perSlotContext(262144, 4)).toBe(65536);
});
it('rounds down, since a fraction of a token is not usable', () => {
expect(perSlotContext(100000, 3)).toBe(33333);
});
it.each([undefined, 0, 1, -2, Number.NaN, Number.POSITIVE_INFINITY])(
'leaves the context whole for an unusable or single slot count (%s)',
(slots) => {
expect(perSlotContext(65536, slots as number | undefined)).toBe(65536);
}
);
});
describe('exceedsSafeContextFloor with a slot count', () => {
it('divides only the context it is given, so a larger /props figure can never skip the warning', () => {
// a --fit-ctx floor of 65,536 over 4 slots is 16,384 each, whatever /props might say about the server
expect(exceedsSafeContextFloor(withContextVar, 65536, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 65536, 1)).toBe(false);
// the measured failure: ~131k over 4 slots is ~33k each
expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 262144, 4)).toBe(false);
});
it('still warns when the context itself is below the minimum', () => {
expect(exceedsSafeContextFloor(withContextVar, 16384, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 16384, 1)).toBe(true);
});
it('keeps its existing behaviour when no slot count is given', () => {
expect(exceedsSafeContextFloor(withContextVar, 16384)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS)).toBe(false);
expect(exceedsSafeContextFloor(withContextVar, undefined)).toBe(false);
});
it('flags a context that only clears the floor as a whole, not per slot', () => {
expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 131072, 1)).toBe(false);
});
it('accepts exactly the floor per slot', () => {
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4, 4)).toBe(false);
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4 - 4, 4)).toBe(true);
});
it('never applies to a CLI that declares no context variable, whatever the slots', () => {
expect(exceedsSafeContextFloor(withoutContextVar, 4096, 8)).toBe(false);
});
});
+30
View File
@@ -30,6 +30,36 @@ async function closedPort(): Promise<number> {
}
describe('buildWebArgs', () => {
it('forwards the tunnel opt-in between the network and multi-user flags', () => {
const options = {
host: '0.0.0.0',
port: 8080,
https: true,
titleHostname: 'tower',
allowUnauthenticatedNetwork: true,
allowUnauthenticatedTunnel: true,
multiuser: true,
};
expect(buildWebArgs(options)).toEqual([
'web',
'--host',
'0.0.0.0',
'--port',
'8080',
'--https',
'--title-hostname',
'tower',
'--allow-unauthenticated-network',
'--allow-unauthenticated-tunnel',
'--multiuser',
]);
});
it.each([false, undefined])('omits the tunnel opt-in when it is %s', (allowUnauthenticatedTunnel) => {
const options = { host: '127.0.0.1', port: 8080, https: false, allowUnauthenticatedTunnel };
expect(buildWebArgs(options)).toEqual(['web', '--host', '127.0.0.1', '--port', '8080']);
});
it('always passes host and port through explicitly', () => {
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
'web',
+207
View File
@@ -0,0 +1,207 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCli } from '../src/config/cli-registry/index.js';
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
import {
applyCustomModelInjection,
removeConfigDir,
customModelConfigDir,
} from '../src/custom-model-injection-apply.js';
afterEach(() => {
removeConfigDir(customModelConfigDir('deepseek-probe'));
vi.unstubAllEnvs();
});
describe('DeepSeek custom endpoint isolation', () => {
it('uses the chosen model and limits without changing installed profiles or embedding the key', () => {
const realHome = join(homedir(), '.dsh');
const profile = join(realHome, 'profiles', 'dsh-tui');
mkdirSync(profile, { recursive: true });
mkdirSync(join(realHome, 'sessions'), { recursive: true });
writeFileSync(join(realHome, 'settings.yaml'), 'theme: dark');
const original = join(profile, 'cordis.patch.yml');
writeFileSync(original, 'original profile');
vi.stubEnv('DSH_HOME', realHome);
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8');
expect(content).not.toContain(endpoint.apiKey);
// The dedicated key var, never DEEPSEEK_API_KEY: dsh's own llm-deepseek/web-search
// rows read that too, and an in-session /model switch back to the official route must
// not take the custom endpoint's key to api.deepseek.com with it.
expect(applied.envOverrides.CODEMAN_CUSTOM_MODEL_API_KEY).toBe(endpoint.apiKey);
expect(applied.envOverrides.DEEPSEEK_API_KEY).toBeUndefined();
const patch = JSON.parse(content);
const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'];
expect(provider.api).toBe('openai-completions');
expect(provider.baseURL).toBe('http://box:8080/v1');
expect(provider.models).toEqual([
{ id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 },
]);
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({
provider: 'codeman-custom',
model: 'chosen-model',
});
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
// The rest of the linkFromRealHome list, consumed generically: `sessions` is the
// transcript root removeConfigDir would otherwise delete, and the user's settings.yaml
// must keep applying through the redirect.
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml'));
// Re-apply onto the same config dir (boot recovery re-runs this every restart): the
// lstat guard must skip every existing link, which the old existsSync check missed:
// recreating them would raise EEXIST.
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
removeConfigDir(applied.configDir);
expect(readFileSync(original, 'utf8')).toBe('original profile');
});
it('creates missing directory targets before linking, skips a missing file target, stays fine on re-apply', () => {
// A fresh install (or relocated DSH_HOME) has never run a turn, so the real home
// carries none of the linked entries. Linking a missing DIRECTORY left a dangling
// symlink whose every write dsh attempts THROUGH it failed with ENOENT on EVERY turn,
// and the lstat guard kept that dangling link forever. Directories are now created
// (0700, what dsh itself does on a first turn) before linking; a missing FILE
// (`settings.yaml`) is simply skipped. A stubbed HOME and CODEMAN_DATA_DIR keep both
// the fake real home and the generated dir off the developer's own dotfiles.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-fresh-home-'));
const realHome = join(sandbox, '.dsh');
mkdirSync(realHome, { recursive: true }); // exists, but no sessions/, no profiles/, no settings.yaml
vi.stubEnv('HOME', sandbox);
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', ''); // force resolution down to the recipe's `realHomeFallback`
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700);
// The missing FILE target is skipped outright, not a dangling link either: lstat
// must find nothing, so a later apply (once the file exists for real) links it.
expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false);
expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined();
// Re-apply stays fine (boot recovery re-runs it every restart): idempotent...
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
// ...and a target deleted AFTER linking keeps the existing link untouched (the
// lstat guard), rather than recreating it and throwing EEXIST.
rmSync(join(realHome, 'sessions'), { recursive: true, force: true });
expect(
applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072)?.envOverrides.DSH_HOME
).toBe(applied.configDir);
expect(readlinkSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('links from the SESSION-supplied DSH_HOME, not the server environment one', () => {
// Both web routes pass the session's own env-override lookup as the seventh
// argument; a user who relocated the dsh home via an allowlisted DSH_HOME
// override must get their state linked from THAT directory, not the server's.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-session-home-'));
const serverHome = join(sandbox, 'server-home');
const sessionHome = join(sandbox, 'session-home');
mkdirSync(join(sessionHome, 'sessions'), { recursive: true });
mkdirSync(join(sessionHome, 'profiles'), { recursive: true });
writeFileSync(join(sessionHome, 'settings.yaml'), 'theme: light');
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', serverHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(
entry,
endpoint,
'chosen-model',
'deepseek-probe',
131072,
undefined,
(k) => (k === 'DSH_HOME' ? sessionHome : undefined)
);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(sessionHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(sessionHome, 'profiles'));
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(sessionHome, 'settings.yaml'));
// The server-environment home was never consulted, let alone created.
expect(existsSync(serverHome)).toBe(false);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('never self-links when the session override IS its own config dir; falls through to the server home', () => {
// Boot recovery re-applies onto a session already holding a previous injection's
// DSH_HOME value; linking that value would point the isolated directory at itself.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-self-home-'));
const serverHome = join(sandbox, 'server-home');
mkdirSync(join(serverHome, 'sessions'), { recursive: true });
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', serverHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(
entry,
endpoint,
'chosen-model',
'deepseek-probe',
131072,
undefined,
(k) => (k === 'DSH_HOME' ? customModelConfigDir('deepseek-probe') : undefined)
);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(serverHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('skips a relative DSH_HOME rather than creating it under the server cwd', () => {
// A session may supply DSH_HOME; `work` would otherwise mkdir `work/sessions` in
// the SERVER's working directory and leave relative links that dangle (a relative
// home resolves against the CLI's cwd, which the server cannot know). Skipping it
// falls through to the recipe's `realHomeFallback`, and nothing is created under cwd.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-relative-home-'));
const fallbackHome = join(sandbox, '.dsh');
mkdirSync(fallbackHome, { recursive: true });
const relativeHome = `dsh-relative-probe-${process.pid}-${Date.now()}`;
vi.stubEnv('HOME', sandbox); // the fallback resolves under homedir()
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', relativeHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(existsSync(join(process.cwd(), relativeHome))).toBe(false);
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(fallbackHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
});
/** readlinkSync without the throw an absent link causes (ENOENT -> undefined). */
function readlinkSafe(path: string): string | undefined {
try {
return readlinkSync(path);
} catch {
return undefined;
}
}
@@ -0,0 +1,76 @@
import { describe, it, expect, afterEach } from 'vitest';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const root = resolve(import.meta.dirname, '..');
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function deepseekInstallCommand(platform: 'linux' | 'darwin'): string {
const ds = STOCK_CLIS.find((c) => (c.id as string) === 'deepseek')!;
return ds.discovery.install.command![platform]!;
}
/**
* Run the stock install command through `/bin/sh -c` (what CLI management does, `shell: true`)
* with a fake `npm` that records each call, on a PATH that holds only the fake bins. So this
* exercises the real string, not a copy of it.
*/
function runInstall(opts: { pnpmOnPath: boolean; npmFails?: boolean }): { status: number | null; npmCalls: string[] } {
const dir = mkdtempSync(join(tmpdir(), 'dsh-install-'));
dirs.push(dir);
const npmBin = join(dir, 'npm-bin');
const pnpmBin = join(dir, 'pnpm-bin');
mkdirSync(npmBin);
mkdirSync(pnpmBin);
const log = join(dir, 'npm.log');
writeFileSync(log, '');
writeFileSync(join(npmBin, 'npm'), '#!/bin/sh\necho "$*" >> "$NPM_LOG"\nexit "${NPM_EXIT:-0}"\n');
chmodSync(join(npmBin, 'npm'), 0o755);
writeFileSync(join(pnpmBin, 'pnpm'), '#!/bin/sh\nexit 0\n');
chmodSync(join(pnpmBin, 'pnpm'), 0o755);
const result = spawnSync('/bin/sh', ['-c', deepseekInstallCommand('linux')], {
env: {
PATH: opts.pnpmOnPath ? `${npmBin}:${pnpmBin}` : npmBin,
NPM_LOG: log,
NPM_EXIT: opts.npmFails ? '1' : '0',
},
encoding: 'utf-8',
});
const npmCalls = readFileSync(log, 'utf-8').split('\n').filter(Boolean);
return { status: result.status, npmCalls };
}
describe('DeepSeek install', () => {
it('is the same command on linux and darwin', () => {
expect(deepseekInstallCommand('darwin')).toBe(deepseekInstallCommand('linux'));
});
it('installs pnpm with dsh when none is on PATH, since `dsh plugin` spawns a literal pnpm', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: false });
expect(status).toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh', 'install -g pnpm']);
});
it('leaves an existing pnpm alone (a corepack shim makes `npm install -g pnpm` fail with EEXIST)', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: true });
expect(status).toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
});
it('reports a failed dsh install and does not go on to pnpm', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: false, npmFails: true });
expect(status).not.toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
});
it("puts dsh's own directory first on the profile install's PATH", () => {
const src = readFileSync(resolve(root, 'src/web/routes/system-routes.ts'), 'utf8');
expect(src).toMatch(/env: \{ \.\.\.process\.env, PATH: \[dir, process\.env\.PATH\]/);
});
});
+5 -8
View File
@@ -4,10 +4,11 @@
* Two of these assertions exist because the obvious implementation was measured
* to be wrong against real files:
*
* - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder stops
* at the first frame end. A 56-line transcript decoded as 1 line / 158 bytes,
* which reads as "the worker never answered" rather than as an error. The
* multi-frame fixtures below are the guard.
* - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder
* before 24.22 and 26.10 stops at the first frame end. A 56-line transcript
* decoded as 1 line / 158 bytes, which reads as "the worker never answered"
* rather than as an error. Newer Node decodes every frame (nodejs/node#65865),
* so the multi-frame fixtures below pin the walker itself, not Node's decoder.
* - a fresh worker in a case directory that had been used before answered its
* first `last-response` with the PREVIOUS session's reply. Session-to-
* transcript pairing is therefore its own describe block.
@@ -67,10 +68,6 @@ describe.skipIf(!zstdSupported())('zstd frame walking', () => {
const lines = Array.from({ length: 40 }, (_, i) => JSON.stringify({ type: 'noise', seq: i }));
const buf = framed(lines);
// The one-shot decoder is what this module had to replace.
const oneShot = (zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }).zstdDecompressSync!(buf);
expect(oneShot.toString('utf8').trim().split('\n')).toHaveLength(1);
expect(zstdFrameRanges(buf)).toHaveLength(40);
expect(decodeZstdFrames(buf).trim().split('\n')).toHaveLength(40);
});
File diff suppressed because it is too large. Load diff
+208
View File
@@ -0,0 +1,208 @@
// @vitest-environment node
/**
* @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no
* Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by
* kind. The bridge and routes are tested on their own; this pins the lines that make the feature
* exist. A real WebServer, never started, with the bridge replaced by a recorder.
*
* Port: N/A (no server start).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
vi.mock('web-push', () => ({
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
}));
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
type Internals = {
pushStore: { getAll: () => never[]; dispose: () => void };
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
sessions: Map<string, unknown>;
mux: { capturePaneText?: (name: string) => string | null };
lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>;
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
broadcast: (event: string, data: unknown) => void;
cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>;
discardPartiallyBuiltSession: (id: string) => Promise<void>;
};
function server(): Internals {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals;
s.pushStore = { getAll: () => [], dispose: () => undefined };
s.discordBridge = {
notify: vi.fn(async () => null),
sessionCreated: vi.fn(async () => undefined),
sessionRenamed: vi.fn(async () => undefined),
sessionDeleted: vi.fn(async () => undefined),
forgetTab: vi.fn(),
stop: vi.fn(),
};
return s;
}
beforeEach(() => sendNotification.mockClear());
describe('bridge deps', () => {
it('agentExited is true only for a session whose pane exit was observed', () => {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
discordBridge: { deps: { agentExited: (id: string) => boolean } };
};
s.sessions.set('dead', { paneExit: { observedAt: 1 } });
s.sessions.set('alive', {});
const { agentExited } = s.discordBridge.deps;
expect(agentExited('dead')).toBe(true);
expect(agentExited('alive')).toBe(false);
expect(agentExited('missing')).toBe(false);
});
it("types a reply as the person's own input, so it can name the tab", async () => {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
discordBridge: { deps: { sendInput: (id: string, text: string) => Promise<boolean> } };
};
const writeViaMux = vi.fn(async () => true);
s.sessions.set('s1', { writeViaMux });
expect(await s.discordBridge.deps.sendInput('s1', 'yes\r')).toBe(true);
expect(writeViaMux).toHaveBeenCalledWith('yes\r', { fromUser: true });
expect(await s.discordBridge.deps.sendInput('missing', 'yes\r')).toBe(false);
});
});
describe('sendPushNotifications -> Discord', () => {
it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => {
const s = server();
s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' });
const capture = vi.fn(() => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n');
s.mux = { capturePaneText: capture };
await s.sendPushNotifications('hook:permission_prompt', {
sessionId: 's1',
sessionName: 'w1-app',
tool_name: 'Bash',
});
expect(s.discordBridge.notify).toHaveBeenCalledTimes(1);
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(msg).toMatchObject({
event: 'hook:permission_prompt',
title: 'Permission Required',
body: '[w1-app] Tool: Bash',
urgency: 'critical',
sessionId: 's1',
sessionName: 'w1-app',
});
// A function, so the screen is read (a synchronous tmux exec) only once the bridge will post.
expect(typeof msg.tail).toBe('function');
expect(capture).not.toHaveBeenCalled();
expect(msg.tail()).toBe('Which option?\n 1. Cap GPUs\n 2. Cap CPU');
expect(capture).toHaveBeenCalledTimes(1);
expect(sendNotification).not.toHaveBeenCalled();
});
it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => {
const s = server();
s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' });
const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' }));
s.lastResponseFor = reader;
s.mux = { capturePaneText: () => 'screen\n❯\n status' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' });
const idle = s.discordBridge.notify.mock.calls[0][0];
expect(await idle.fullText()).toEqual({
text: 'The full answer.\n\n- one\n- two',
version: '2026-10-30T00:00:00Z',
});
expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut
await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' });
const dialog = s.discordBridge.notify.mock.calls[1][0];
expect(dialog.fullText).toBeUndefined();
expect(dialog.tail()).toBe('screen\n❯\n status');
});
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])(
'a finished turn in a %s tab posts the whole last message',
async (mode) => {
const s = server();
s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode });
s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' }));
s.mux = { capturePaneText: () => 'screen' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?');
}
);
it('a shell tab has no turns, so it keeps the screen tail', async () => {
const s = server();
s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' });
s.lastResponseFor = vi.fn(async () => ({ text: 'x' }));
s.mux = { capturePaneText: () => 'prompt$' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(await msg.fullText()).toBeUndefined();
expect(msg.tail()).toBe('prompt$');
});
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
const s = server();
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' });
expect(s.discordBridge.notify.mock.calls[0][0].tail()).toBe('plain answer');
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' });
expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined();
});
it('sends nothing for an event Web Push does not carry', async () => {
const s = server();
await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' });
expect(s.discordBridge.notify).not.toHaveBeenCalled();
});
});
describe('broadcast -> Discord tab lifecycle', () => {
it('routes created, updated and deleted by kind, reading both payload shapes', () => {
const s = server();
s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } });
expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1');
s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' });
expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed');
});
it('does not touch the thread on session:deleted, which a detach also fires', () => {
const s = server();
s.broadcast(SseEvent.SessionDeleted, { id: 's1' });
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
});
it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => {
const s = server();
await s.cleanupSession('gone', false);
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
await s.cleanupSession('gone', true);
expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone');
});
it('a discarded half-built session clears the per-tab state but keeps its thread', async () => {
const s = server();
s.sessions.set('rebuild', {
owner: 'u',
ralphTracker: { stopWatchingFixPlan: () => undefined, fullReset: () => undefined },
removeAllListeners: () => undefined,
stop: async () => undefined,
});
await s.discardPartiallyBuiltSession('rebuild');
expect(s.discordBridge.forgetTab).toHaveBeenCalledWith('rebuild');
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
});
it('ignores an event with no session id', () => {
const s = server();
s.broadcast(SseEvent.SessionUpdated, {});
expect(s.discordBridge.sessionRenamed).not.toHaveBeenCalled();
});
it('closes the bridge when the server stops', async () => {
const s = server();
await (s as unknown as { stop: () => Promise<void> }).stop();
expect(s.discordBridge.stop).toHaveBeenCalledTimes(1);
});
});
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
/**
* @fileoverview The Discord startup hint reaches Claude as `--append-system-prompt`, only while the
* bridge and its `startupHint` setting are on, and never changes a launch that does not use it.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getCli } from '../src/config/cli-registry/registry.js';
import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js';
import {
DiscordBridge,
DISCORD_STARTUP_HINT,
getDiscordStartupHint,
writeDiscordConfig,
DEFAULT_DISCORD_CONFIG,
} from '../src/discord-bridge.js';
const SID = '0f9c2b14-1111-2222-3333-444455556666';
const BASE = 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"';
function launch(mode: 'claude' | 'codex'): string | undefined {
return buildSpawnCommandFromRegistry(getCli(mode)!, { mode, sessionId: SID, claudeCliVersion: null });
}
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'discord-hint-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function bridge() {
return new DiscordBridge({
configDir: dir,
fetchImpl: async () => new Response('{}'),
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
}
describe('startup hint on the Claude launch', () => {
it('is absent by default, so a launch is byte-identical to before', () => {
expect(getDiscordStartupHint()).toBeUndefined();
expect(launch('claude')).toBe(BASE);
});
it('is appended, single-quoted, while the bridge is on', async () => {
await writeDiscordConfig(dir, {
...DEFAULT_DISCORD_CONFIG,
enabled: true,
token: 'tok',
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
});
const b = bridge();
await b.reload();
expect(getDiscordStartupHint()).toBe(DISCORD_STARTUP_HINT);
expect(launch('claude')).toBe(`${BASE} --append-system-prompt '${DISCORD_STARTUP_HINT}'`);
expect(DISCORD_STARTUP_HINT).not.toContain("'");
expect(launch('codex')).not.toContain('append-system-prompt');
b.stop();
expect(launch('claude')).toBe(BASE);
});
it('is absent when startupHint is switched off', async () => {
await writeDiscordConfig(dir, {
...DEFAULT_DISCORD_CONFIG,
enabled: true,
token: 'tok',
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
startupHint: false,
});
const b = bridge();
await b.reload();
expect(launch('claude')).toBe(BASE);
b.stop();
});
});
+12 -5
View File
@@ -299,8 +299,8 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
* exactly what happened twice: a comment added above the assertion pushed the
* asserted line past the cutoff and CI failed on a test that was still true.
*/
const refreshFn = (src) => {
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
const methodFn = (src, signature) => {
const start = src.indexOf(signature);
expect(start).toBeGreaterThan(-1);
const open = src.indexOf('{', start);
let depth = 0;
@@ -308,23 +308,30 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error('unbalanced braces in _refreshRunModeAvailability');
throw new Error(`unbalanced braces in ${signature}`);
};
// The per-mode decision lives in ONE helper, shared by the Run menu and the
// Cmd+K new-session row (panels-ui.js), so the two cannot drift apart.
const ruleFn = (src) => methodFn(src, '_runModeAvailableForCase(mode, caseName) {');
it('gates a docker case on availableModes instead of host CLI probes', () => {
// The sandbox host had codex but no claude while the adopted container had
// claude and no codex; gating on the host hid the only mode that worked.
const fn = refreshFn(ui);
const fn = ruleFn(ui);
expect(fn).toContain("location === 'docker'");
expect(fn).toContain('availableModes');
// Non-docker cases must keep the original host probe (#201).
expect(fn).toContain('this.isCliAvailable(mode)');
// And the Run menu asks that helper rather than keeping its own copy.
expect(methodFn(ui, '_refreshRunModeAvailability(menu) {')).toContain(
'this._runModeAvailableForCase(mode, caseName)'
);
});
it('leaves an owned container ungated when nothing was probed', () => {
// Our base image ships every CLI, so an absent list means "unknown", and
// treating unknown as "nothing available" would empty the menu.
expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
expect(ruleFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
});
});
+1 -1
View File
@@ -128,7 +128,7 @@ describe('docker server image divergence is declared, not accidental', () => {
// untouched by this change because two other open PRs already modify it. Asserting the
// omissions here makes the divergence reviewable without editing the file: if someone adds
// a CLI there, or the intent changes, this fails and the list has to be restated.
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp']);
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot']);
it('installs exactly the CLIs it declares, and no more', () => {
for (const entry of enabledAgents) {
+16 -1
View File
@@ -26,7 +26,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, chownSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -309,6 +309,21 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md
const casesPath = join(dir, 'cases');
const socketPath = join(dir, 'docker.sock'); // deliberately NOT a real socket — see above
mkdirSync(appdataPath);
// Both scripts derive PUID:PGID from the appdata owner and REFUSE a
// root-owned one (correctly: the container must never run as root).
// mkdirSync gives it the runner's uid, so under a root runner (a CI or
// agent container) the update path stopped at that guard before its
// build. Give it the non-root owner docker/README.md's own
// `chown -R 99:100` example sets up, as a real deployment would have.
if (process.getuid?.() === 0) {
try {
chownSync(appdataPath, 99, 100);
} catch {
// No CAP_CHOWN (root with --cap-drop ALL, as Codeman's own Docker
// case containers run) or a single-uid user namespace: leave it
// root-owned and let the scripts' own guard refuse, as before.
}
}
mkdirSync(casesPath);
writeFileSync(socketPath, '');
+10
View File
@@ -106,6 +106,16 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID');
expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d');
expect(cmd).toContain("sh -lc '");
// The in-container tmux keeps prefix C-q. Without this reduction its stock
// prefix table runs switch-client / choose-tree for the inner client.
// Bind, unbind-all, bind again: an empty prefix table makes unbind-key -a
// fail and abort the rest of the \; chain.
const firstBind = cmd.indexOf('bind-key -T prefix C-q send-prefix');
const unbind = cmd.indexOf('unbind-key -a -T prefix');
const secondBind = cmd.indexOf('bind-key -T prefix C-q send-prefix', unbind);
expect(firstBind).toBeGreaterThan(cmd.indexOf('prefix C-q'));
expect(unbind).toBeGreaterThan(firstBind);
expect(secondBind).toBeGreaterThan(unbind);
});
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
+23
View File
@@ -28,6 +28,7 @@ import {
persistDockerCaseClaudeSessionId,
probeDockerCliVersion,
readDockerCases,
readDockerCasesOrThrow,
readDockerHosts,
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
@@ -70,6 +71,28 @@ describe('docker-hosts storage', () => {
expect(await readDockerCases(dir)).toEqual([]);
});
/**
* The git fetch paths read the list through the strict reader: the writer truncates and rewrites the file in
* place, and a list read as `[]` there would let a fetch run git inside a Docker workspace.
*/
it('has a strict reader that rejects a list it cannot parse, where the plain reader returns []', async () => {
expect(await readDockerCasesOrThrow(dir)).toEqual([]); // missing: no cases
await writeDockerCases(dir, [CASE]);
expect(await readDockerCasesOrThrow(dir)).toEqual([CASE]);
const path = join(dir, 'docker-cases.json');
for (const text of ['', '[{"name":"myproj","hostWorkspacePath":"/home/arkon/cas', '{"name":"myproj"}', 'null']) {
writeFileSync(path, text);
expect(await readDockerCases(dir)).toEqual([]);
const err = await readDockerCasesOrThrow(dir).then(
() => null,
(e: Error) => e
);
expect(err?.message).toMatch(/not a valid JSON array/);
// The parser's message quotes the file; the strict reader's never does.
expect(err?.message).not.toContain('/home/arkon/cas');
}
});
it('persists the last Claude conversation id keyed by container name', async () => {
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
+165
View File
@@ -0,0 +1,165 @@
/**
* Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset
* reads of the main transcript and its subagents, and the image probe.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
findClaudeTranscript,
probeLocalPaths,
readClaudeEditSources,
resolveClaudeTranscript,
} from '../src/edit-history-local-source.js';
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'edit-src-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function put(rel: string, content: string | Buffer, mtimeSec?: number): string {
const path = join(root, rel);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, content);
if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec);
return path;
}
describe('findClaudeTranscript', () => {
it('returns null when no transcript exists under any id', async () => {
put('p/other.jsonl', 'x\n');
expect(await findClaudeTranscript(['sid'], root)).toBeNull();
expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull();
});
it('takes the first id that has a transcript, the newest one across project dirs', async () => {
put('a/sid.jsonl', 'old\n', 1000);
const newest = put('b/sid.jsonl', 'new\n', 2000);
put('c/second.jsonl', 'x\n');
expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest);
});
});
describe('resolveClaudeTranscript', () => {
it('takes the watcher path (what the hooks reported) as is', async () => {
expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl');
});
it("without one, looks under the session's own claude config dir", async () => {
const path = put('cfg/projects/p/sid.jsonl', 'x\n');
expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path);
expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull();
});
});
describe('readClaudeEditSources', () => {
it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => {
const path = put('p/sid.jsonl', 'x\n');
expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull();
expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull();
expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull();
expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull();
});
it('names the conversation after the file', async () => {
const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100);
expect(res?.transcriptId).toBe('conv-1');
});
it('reads a new file from its end within the budget, a requested one from its offset', async () => {
const path = put('p/sid.jsonl', '0123456789');
const fresh = await readClaudeEditSources(path, [], 4);
expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]);
const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100);
expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') });
});
it('clamps an offset past the end to the size (a replaced, shorter file)', async () => {
const path = put('p/sid.jsonl', 'abc');
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100);
expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) });
});
it('lists subagents with their meta for new ones only, and shares the budget main first', async () => {
const path = put('p/sid.jsonl', 'MAIN');
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}');
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}');
put('p/sid/subagents/notes.txt', 'ignored');
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6);
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['main', 0, 'MAIN'],
['subagents/agent-a1.jsonl', 2, 'AA'],
['subagents/agent-b2.jsonl', 0, ''],
]);
expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' });
});
it('ignores invalid requests', async () => {
const path = put('p/sid.jsonl', 'abcdef');
const res = await readClaudeEditSources(
path,
[
{ relPath: '../etc/passwd', offset: 0 },
{ relPath: 'main', offset: -1 },
],
3
);
// The invalid main request is dropped, so main is read like a new file.
expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') });
});
it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => {
const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8'));
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
});
it('with onlyRequested, reads the requested files and nothing else', async () => {
const path = put('p/sid.jsonl', 'MAIN');
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 2 }], 100, {
onlyRequested: true,
});
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['subagents/agent-b2.jsonl', 1, 'BB'],
]);
});
it('reads the end of a block that does not fit the budget', async () => {
const path = put('p/sid.jsonl', '0123456789');
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2, len: 6 }], 3);
expect(res?.files[0]).toMatchObject({ startOffset: 5, chunk: Buffer.from('567') });
});
});
describe('probeLocalPaths', () => {
it('reports size and mtime of files, expands ~/ against home, null for missing and directories', async () => {
put('home/img.png', 'PNG!', 1_700_000_000);
mkdirSync(join(root, 'dir'));
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
expect(res[1]).toBeNull();
expect(res[2]).toBeNull();
});
it('touches a path only after the bounded probe saw a file there', async () => {
put('a.png', 'PNG!', 1_700_000_000);
put('b.png', 'PNG!', 1_700_000_000);
const asked: string[] = [];
const res = await probeLocalPaths([join(root, 'a.png'), join(root, 'b.png'), 'rel.png'], root, async (p) => {
asked.push(p);
return p.endsWith('a.png') ? 'unknown' : 'file';
});
// a.png exists, but its filesystem did not answer the bounded probe: no unbounded stat.
expect(res.map((r) => r?.realPath ?? null)).toEqual([null, join(root, 'b.png'), null]);
expect(asked).toEqual([join(root, 'a.png'), join(root, 'b.png')]); // relative: never probed
});
});
+420
View File
@@ -0,0 +1,420 @@
/**
* Edit history parser: claude transcript lines → edit records and Bash image candidates.
* Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the
* fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`).
*/
import { describe, expect, it } from 'vitest';
import {
classifyEditPath,
extractImagePathCandidates,
MAX_HUNK_BYTES,
MAX_IMAGE_CANDIDATES,
MAX_PENDING_BASH,
MAX_PENDING_COMMAND_CHARS,
MAX_PENDING_FIELD_CHARS,
parseClaudeEditLines,
} from '../src/edit-history-parse.js';
const CWD = '/work/repo';
function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'user',
isSidechain: false,
timestamp: ts,
cwd: CWD,
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult,
...extra,
});
}
function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'assistant',
isSidechain: false,
timestamp: ts,
cwd: CWD,
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
...extra,
});
}
const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines });
const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', {
filePath: '/work/repo/src/app.ts',
oldString: 'a',
newString: 'b',
originalFile: 'x'.repeat(5000),
structuredPatch: [hunk([' keep', '-old', '+new', '+added'])],
userModified: false,
replaceAll: false,
});
describe('parseClaudeEditLines: edits', () => {
it('turns an Edit result into a record without the whole-file fields', () => {
const { records } = parseClaudeEditLines(editLine + '\n');
expect(records).toEqual([
{
id: 'toolu_edit',
ts: Date.parse('2026-10-15T19:01:28.880Z'),
path: '/work/repo/src/app.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 2,
removed: 1,
hunks: [hunk([' keep', '-old', '+new', '+added'])],
truncated: false,
},
]);
expect(JSON.stringify(records)).not.toContain('xxxxx');
});
it('reads replaceAll edits like any other Edit', () => {
const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', {
filePath: '/work/repo/a.ts',
oldString: 'x',
newString: 'y',
originalFile: '',
structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }],
userModified: false,
replaceAll: true,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 });
expect(rec.hunks).toHaveLength(2);
});
it('marks a Write that created the file, and one that replaced it', () => {
const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', {
type: 'create',
filePath: '/work/repo/new.md',
content: 'hello\nworld',
structuredPatch: [],
originalFile: null,
});
const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', {
type: 'update',
filePath: '/work/repo/new.md',
content: 'hi',
structuredPatch: [hunk(['-hello', '-world', '+hi'])],
originalFile: 'hello\nworld',
});
const { records } = parseClaudeEditLines([create, update].join('\n'));
expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([
['Write', true, 2, 0],
['Write', false, 1, 2],
]);
expect(records[0].hunks).toEqual([
{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] },
]);
});
it('a created file’s diff drops the trailing newline and is capped like any hunk', () => {
const big = 'x'.repeat(1000) + '\n';
const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', {
type: 'create',
filePath: '/work/repo/big.txt',
content: big.repeat(100),
structuredPatch: [],
originalFile: null,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec.added).toBe(100);
expect(rec.truncated).toBe(true);
expect(rec.hunks[0].lines.length).toBeLessThan(100);
expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true);
});
it('classifies an image written by Write as an image', () => {
const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', {
type: 'create',
filePath: '/work/repo/icon.SVG',
content: '<svg/>',
structuredPatch: [],
originalFile: null,
});
expect(parseClaudeEditLines(line).records[0].kind).toBe('image');
});
it('keeps a result of an unknown edit tool that has a path and a patch', () => {
const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', {
filePath: '/work/repo/n.ipynb',
structuredPatch: [hunk(['+cell'])],
});
expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 });
});
it('skips failed tool results (a string), unrelated results and lines that do not parse', () => {
const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found');
const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } });
const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n');
expect(parseClaudeEditLines(text).records).toEqual([]);
});
it('skips a first line cut mid-record (a byte range starts inside it)', () => {
const cut = editLine.slice(40);
expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1);
});
it('carries the subagent id of a sidechain line', () => {
const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, {
isSidechain: true,
agentId: 'a0038561ea4d57a42',
});
expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42');
});
it('caps an oversized patch and says so, counting lines from the whole patch', () => {
const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`);
const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', {
type: 'create',
filePath: '/work/repo/big.ts',
content: '',
structuredPatch: [hunk(big)],
originalFile: null,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec.truncated).toBe(true);
expect(rec.added).toBe(4000);
expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024);
expect(rec.hunks[0].lines.length).toBeLessThan(4000);
});
});
describe('parseClaudeEditLines: Bash image candidates', () => {
const start = '2026-10-15T20:00:00.000Z';
const end = '2026-10-15T20:00:05.000Z';
it('pairs a Bash call with its result and collects image paths from command and output', () => {
const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png');
const res = toolResultLine('toolu_b', end, {
stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.',
stderr: '',
interrupted: false,
isImage: false,
});
const { candidates, pending } = parseClaudeEditLines([use, res].join('\n'));
expect(pending).toEqual([]);
expect(candidates).toEqual([
{
toolUseId: 'toolu_b',
start: Date.parse(start),
end: Date.parse(end),
paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'],
},
]);
});
it('yields no candidate for a Bash call without image paths', () => {
const use = bashUseLine('toolu_n', start, 'git status');
const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false });
expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]);
});
it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => {
const use = bashUseLine('toolu_f', start, 'make render');
const res = JSON.stringify({
type: 'user',
timestamp: end,
cwd: CWD,
message: {
role: 'user',
content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }],
},
toolUseResult: 'Error: Exit 2',
});
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']);
});
it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => {
const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n');
expect(first.candidates).toEqual([]);
expect(first.pending).toEqual([
{ id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD },
]);
const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
const second = parseClaudeEditLines(res, first.pending);
expect(second.pending).toEqual([]);
expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']);
});
it('carries the subagent id onto the candidate', () => {
const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' });
const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc');
});
it('bounds the pending list, dropping the oldest', () => {
const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) =>
bashUseLine(`toolu_${i}`, start, 'sleep 1')
).join('\n');
const { pending } = parseClaudeEditLines(lines);
expect(pending).toHaveLength(MAX_PENDING_BASH);
expect(pending[0].id).toBe('toolu_5');
});
it(`keeps no call with an id, cwd or agent id over ${MAX_PENDING_FIELD_CHARS} characters (the store would refuse it on load)`, () => {
const long = 'x'.repeat(MAX_PENDING_FIELD_CHARS + 1);
const lines = [
bashUseLine(long, start, 'render > a.png'),
bashUseLine('toolu_cwd', start, 'render > a.png', { cwd: '/' + long }),
bashUseLine('toolu_agent', start, 'render > a.png', { agentId: long }),
bashUseLine('toolu_ok', start, 'render > a.png', { cwd: '/' + 'x'.repeat(MAX_PENDING_FIELD_CHARS - 1) }),
];
expect(parseClaudeEditLines(lines.join('\n')).pending.map((p) => p.id)).toEqual(['toolu_ok']);
});
it('keeps only the head of a long command (the pending list is persisted)', () => {
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS));
});
});
describe('parseClaudeEditLines: images sent to the user', () => {
it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => {
const line = toolResultLine(
'toolu_send',
'2026-10-15T20:50:12.538Z',
{
caption: 'Vier Alternativen',
attachments: [
{ path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' },
{ path: '/tmp/s/icons/bremse_alt1_pedal.png' },
{ path: '/tmp/s/report.pdf' },
{ path: 'relative.png' },
{ nope: 1 },
],
},
{ agentId: 'a1' }
);
const { candidates, records } = parseClaudeEditLines(line);
expect(records).toEqual([]);
const at = Date.parse('2026-10-15T20:50:12.538Z');
expect(candidates).toEqual([
{
toolUseId: 'toolu_send',
start: at,
end: at,
paths: ['/tmp/s/icons/bremse_alt1_pedal.png'],
sent: true,
agentId: 'a1',
},
]);
});
});
describe('extractImagePathCandidates', () => {
it('keeps a colon inside a file:// path (timestamped names)', () => {
expect(extractImagePathCandidates('', 'Saved to file:///tmp/plot-12:30.png', '', CWD)).toEqual([
'/tmp/plot-12:30.png',
]);
});
it('resolves relative paths against cwd and keeps ~/ paths literal', () => {
expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([
'/work/repo/a.png',
'/work/b.jpeg',
'~/shots/c.gif',
]);
});
it('reads quoted paths with spaces', () => {
expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([
'/work/repo/in dir/x.png',
'/work/repo/out dir/y.avif',
]);
});
it('ignores URLs, globs and names that only contain an image extension', () => {
const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png';
expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']);
});
it('splits key=value and trailing punctuation off', () => {
expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([
'/work/repo/out/a.png',
'/work/repo/out/b.png',
'/work/repo/out/c.png',
]);
});
it('deduplicates and caps the list', () => {
const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' ');
const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD);
expect(out).toHaveLength(MAX_IMAGE_CANDIDATES);
expect(out[0]).toBe('/work/repo/a.png');
expect(out[1]).toBe('/work/repo/f0.png');
});
it('drops relative paths when cwd is unknown', () => {
expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']);
});
});
describe('extractImagePathCandidates: linear in the line length (#693 review)', () => {
// Claude Code keeps up to 30,000 characters of Bash output, all of it agent-printed, and the
// scan runs synchronously in the request. Before the fix one such line took ~2.7 s.
const N = 30_000;
// Generous: the gated suite runs at nice 19 next to the live service. Linear is ~1 ms.
const BUDGET_MS = 1000;
const timed = (text: string) => {
const t = performance.now();
const out = extractImagePathCandidates('render', text, '', CWD);
return { out, ms: performance.now() - t };
};
const base64 = Buffer.from(Array.from({ length: (N * 3) / 4 }, (_, i) => (i * 131 + 7) & 255)).toString('base64');
it.each([
...'#+%@![{^:/a.~-$'.split('').map((c) => [`30,000 × ${c}`, c.repeat(N)] as const),
['a base64 blob', base64] as const,
['repeated file:///', 'file:///'.repeat(N / 8)] as const,
['repeated file:///x', 'file:///x'.repeat(N / 9)] as const,
['repeated file:///a:', 'file:///a:'.repeat(N / 10)] as const,
['file:/// then 30,000 characters', 'file:///' + 'a'.repeat(N)] as const,
['an unclosed quote', '"' + 'a'.repeat(N)] as const,
['repeated =a', '=a'.repeat(N / 2)] as const,
])('%s stays under the time budget and still finds a path after it', (_label, line) => {
const { out, ms } = timed(`${line}\nsaved out/plot.png`);
expect(ms).toBeLessThan(BUDGET_MS);
expect(out).toContain('/work/repo/out/plot.png');
});
it('parses a chunk of 139 Bash results with a 30,000-character base64 blob each under budget', () => {
const lines: string[] = [];
for (let i = 0; i < 139; i++) {
lines.push(bashUseLine(`toolu_${i}`, '2026-10-15T20:00:00.000Z', 'cat img.b64'));
lines.push(
toolResultLine(`toolu_${i}`, '2026-10-15T20:00:01.000Z', {
stdout: base64,
stderr: '',
interrupted: false,
isImage: false,
})
);
}
const t = performance.now();
parseClaudeEditLines(lines.join('\n'));
expect(performance.now() - t).toBeLessThan(3000); // 6.6 s before the fix
});
});
describe('classifyEditPath', () => {
it.each([
['/a/b.png', 'image'],
['/a/b.JPG', 'image'],
['/a/b.jpeg', 'image'],
['/a/b.webp', 'image'],
['/a/b.gif', 'image'],
['/a/b.avif', 'image'],
['/a/b.svg', 'image'],
['/a/b.ts', 'code'],
['/a/png', 'code'],
['/a/b.png.orig', 'code'],
])('%s → %s', (path, kind) => {
expect(classifyEditPath(path)).toBe(kind);
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
* temp transcripts: what the fakes in edit-history-sync.test.ts cannot show (file order,
* a new conversation next to the old one, every line read exactly once).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readClaudeEditSources } from '../src/edit-history-local-source.js';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js';
let root: string;
const opened: EditHistoryStore[] = [];
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'edit-pipeline-'));
});
afterEach(async () => {
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(root, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
/** One edit result line of `pad` bytes or more, newline included. */
function editLine(id: string, i: number, pad = 0, agentId?: string): string {
const rec: Record<string, unknown> = {
type: 'user',
timestamp: new Date(T0 + i * 1000).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: `/w/${id}.ts`,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
},
pad: '',
};
if (agentId) rec.agentId = agentId;
rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1));
return JSON.stringify(rec) + '\n';
}
function setup(budget?: number, base = root) {
const project = join(base, 'projects', 'p');
mkdirSync(project, { recursive: true });
const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`);
/** The transcript watcher's path (the hooks report it; `/clear` moves it). */
let current = at('convA');
const sync = new EditHistorySync({
transcriptPath: async () => (existsSync(current) ? current : null),
readSources: readClaudeEditSources,
probe: async (paths) => paths.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }),
sweepStores: async () => 0,
budget,
});
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' };
const ids = async () =>
(await sync.getStore('s1'))
.list()
.map((e) => e.id)
.sort();
return {
project,
at,
sync,
session,
ids,
switchTo: (transcriptId: string) => (current = at(transcriptId)),
};
}
describe('a new conversation (/clear, resume)', () => {
it('reads the previous transcript to its end before the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
// The turn's last edits land, then /clear before the panel's debounced re-read.
appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']);
const store = await sync.getStore('s1');
expect(store.getTranscriptId()).toBe('convB');
expect(store.knownSources()).toEqual(['main']);
});
it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
appendFileSync(at('convA'), editLine('a0', 0));
await sync.sync(session);
const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`);
appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join(''));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session);
expect(await ids()).not.toContain('b1');
for (let i = 0; i < 5; i++) await sync.sync(session);
expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort());
});
it('subagents of the previous conversation are read to their end too', async () => {
const { project, at, sync, session, ids, switchTo } = setup();
const sub = join(project, 'convA', 'subagents');
mkdirSync(sub, { recursive: true });
appendFileSync(at('convA'), editLine('a1', 1));
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x'));
await sync.sync(session);
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']);
});
it('a previous transcript that is gone does not hold up the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
rmSync(at('convA'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 2));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1']);
});
it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
const older = Array.from({ length: 4 }, (_, i) => `a${i}`);
appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join(''));
await sync.sync(session);
expect(await ids()).toEqual(['a3']);
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session, { older: true });
expect(await ids()).toEqual(['a2', 'a3']);
});
});
describe('every line exactly once (seeded random transcripts)', () => {
/** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */
async function run(seed: number): Promise<string | null> {
let state = seed;
const rnd = () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
const budget = ri(400, 1500);
const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-')));
const main = at('convA');
const subDir = join(project, 'convA', 'subagents');
const expected = new Set<string>();
const tooLong = new Set<string>();
const agents: string[] = [];
const tails = new Map<string, string>();
let n = 0;
const write = () => {
if (rnd() < 0.15 && agents.length < 6) {
mkdirSync(subDir, { recursive: true });
agents.push(`a${agents.length}`);
}
const k = ri(0, agents.length);
const agent = k === 0 ? undefined : agents[k - 1];
const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main;
const id = `e${n++}`;
// Some lines are longer than the budget: skipped, never shown, never blocking.
const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent);
if (Buffer.byteLength(line) < budget) expected.add(id);
else if (Buffer.byteLength(line) > budget) tooLong.add(id);
// Sometimes half a line: claude is still writing it.
const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length;
appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut));
if (cut < line.length) tails.set(file, line.slice(cut));
else tails.delete(file);
};
for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens
if (!existsSync(main)) appendFileSync(main, '');
for (let r = ri(5, 25); r > 0; r--) {
for (let i = ri(0, 6); i > 0; i--) write();
await sync.sync(session);
}
for (const [file, rest] of tails) appendFileSync(file, rest);
for (let i = 0; i < 40; i++) await sync.sync(session);
const store = await sync.getStore('s1');
for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true });
const got = new Set(store.list().map((e) => e.id));
const missing = [...expected].filter((id) => !got.has(id));
const extra = [...got].filter((id) => tooLong.has(id));
if (missing.length || extra.length || store.backfillTruncated) {
return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`;
}
return null;
}
it('reads every line shorter than the budget once, forward and back, and none longer', async () => {
const failures: string[] = [];
for (let seed = 1; seed <= 40; seed++) {
await Promise.all(opened.splice(0).map((s) => s.remove()));
const r = await run(seed);
if (r) failures.push(r);
}
expect(failures).toEqual([]);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Edit history image probe against a filesystem that stopped answering (#693 review): a Bash
* call printed a path on a dead hard mount, whose stat() never settles. The real sync with the
* real probe (`probeLocalPaths` → bounded `probePathKind`) must still finish, the next queued
* sync too, and `remove()` must not wait on it. Own file: the stalled probe stays recorded
* process-wide for the rest of the run.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const DEAD = '/mnt/dead-nas';
vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
});
vi.mock('node:fs/promises', async (importOriginal) => {
const real = await importOriginal<typeof import('node:fs/promises')>();
const hangs = (p: unknown) => String(p).startsWith('/mnt/dead-nas');
const stat = ((p: Parameters<typeof real.stat>[0], ...rest: unknown[]) =>
hangs(p) ? new Promise(() => {}) : (real.stat as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.stat;
const realpath = ((p: Parameters<typeof real.realpath>[0], ...rest: unknown[]) =>
hangs(p)
? new Promise(() => {})
: (real.realpath as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.realpath;
return { ...real, stat, realpath, default: { ...real, stat, realpath } };
});
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { probeLocalPaths, type EditSources } from '../src/edit-history-local-source.js';
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
function bashPair(id: string, start: number, end: number, stdout: string): string {
const use = {
type: 'assistant',
timestamp: new Date(start).toISOString(),
cwd: '/w',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'render' } }] },
};
const res = {
type: 'user',
timestamp: new Date(end).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: stdout }] },
toolUseResult: { stdout, stderr: '', interrupted: false, isImage: false },
};
return JSON.stringify(use) + '\n' + JSON.stringify(res) + '\n';
}
/** Rejects after `ms`, so a hang fails the test instead of timing it out silently. */
function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
return Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${what} still pending after ${ms} ms`)), ms)),
]);
}
let dir: string;
let stores: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-stall-'));
stores = join(dir, 'stores');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('edit history image probe on an unresponsive mount', () => {
it('finishes sync, a queued sync and remove() while one printed path never answers', async () => {
const ok = join(dir, 'ok.png');
writeFileSync(ok, 'PNG!');
utimesSync(ok, new Date(T0 + 2000), new Date(T0 + 2000));
let main = Buffer.from(bashPair('b1', T0, T0 + 5000, `${DEAD}/plots/a.png\nsaved ${ok}\n`));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: async (_path, requests, budget): Promise<EditSources> => {
const req = requests.find((r) => r.relPath === 'main');
const start = Math.min(req?.offset ?? 0, main.length);
const len = Math.min(budget, main.length - start);
return {
transcriptId: 'conv1',
files: [{ relPath: 'main', size: main.length, startOffset: start, chunk: main.subarray(start, start + len) }],
metas: {},
};
},
probe: (paths) => probeLocalPaths(paths),
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
sweepStores: async () => 0,
});
const first = sync.sync(session);
main = Buffer.concat([main, Buffer.from(bashPair('b2', T0 + 6000, T0 + 7000, `${DEAD}/plots/b.png\n`))]);
const second = sync.sync(session);
expect(await within(first, 1500, 'first sync')).toEqual({ state: 'ok' });
expect(await within(second, 1500, 'queued sync')).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.path)).toEqual([ok]);
await within(sync.remove('s1'), 1500, 'remove()');
});
});
+704
View File
@@ -0,0 +1,704 @@
/**
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
* Transcript bytes are built from JSONL lines shaped like real claude records.
*/
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
EditHistoryStore,
entryBytes,
MAIN_SOURCE,
MAX_AGENT_META_CHARS,
MAX_AGENTS,
MAX_ENTRIES,
MAX_FILE_BYTES,
MAX_STORE_BYTES,
parseAgentMeta,
READ_BUDGET_BYTES,
TMP_TAG,
} from '../src/edit-history-store.js';
import { MAX_HUNK_BYTES } from '../src/edit-history-parse.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'user',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
originalFile: 'a',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
userModified: false,
replaceAll: false,
},
...extra,
});
}
/** An Edit whose diff carries `size` bytes of added text. */
function bigEditLine(id: string, sec: number, size: number): string {
const line = JSON.parse(editLine(id, sec));
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
return JSON.stringify(line);
}
function bashUse(id: string, sec: number, command: string): string {
return JSON.stringify({
type: 'assistant',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
});
}
function bashResult(id: string, sec: number): string {
return JSON.stringify({
type: 'user',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
});
}
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
/**
* Read like the reader (edit-history-local-source.ts): on from the cursor of the current
* conversation (clamped to the size), else the file's last `budget` bytes; then apply.
* Null when nothing is new.
*/
function syncForward(
store: EditHistoryStore,
file: Buffer,
budget = READ_BUDGET_BYTES,
source = MAIN_SOURCE,
tid = 'conv-1'
) {
const cursor = tid === store.getTranscriptId() ? store.getCursor(source) : undefined;
const offset = cursor ? Math.min(cursor.offset, file.length) : Math.max(0, file.length - budget);
const len = Math.min(budget, file.length - offset);
if (cursor && offset === cursor.offset && len === 0) return null;
const chunk = file.subarray(offset, offset + len);
return store.apply({ transcriptId: tid, source, startOffset: offset, chunk, size: file.length });
}
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
const range = store.olderRange(source, budget);
if (!range) return null;
const chunk = file.subarray(range.offset, range.offset + range.len);
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
}
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
describe('EditHistoryStore: forward reads', () => {
it('reads a small file whole, then only what was appended', () => {
const store = new EditHistoryStore('s1', { dir });
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
expect(syncForward(store, file)?.added).toBe(2);
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
expect(syncForward(store, file)).toBeNull();
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
expect(syncForward(store, file)?.added).toBe(1);
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
expect(store.backfillTruncated).toBe(false);
});
it('leaves a partial last line (still being written) for the next read', () => {
const store = new EditHistoryStore('s1', { dir });
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
const firstLineEnd = full.indexOf(0x0a) + 1;
const partial = full.subarray(0, firstLineEnd + 30);
expect(syncForward(store, partial)?.added).toBe(1);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
expect(syncForward(store, full)?.added).toBe(1);
expect(ids(store)).toEqual(['e2', 'e1']);
});
it('deduplicates by tool_use_id', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
expect(syncForward(store, file)?.added).toBe(1);
expect(store.list()).toHaveLength(1);
});
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
const store = new EditHistoryStore('s1', { dir });
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
const file = jsonl(lines);
const budget = Math.floor(file.length / 3);
syncForward(store, file, budget);
const cursor = store.getCursor(MAIN_SOURCE)!;
expect(cursor.offset).toBe(file.length);
expect(cursor.floor).toBeGreaterThan(file.length - budget);
expect(file[cursor.floor - 1]).toBe(0x0a);
expect(store.backfillTruncated).toBe(true);
expect(ids(store)[0]).toBe('e19');
expect(ids(store)).not.toContain('e0');
});
it('reads a backlog in budget-sized forward steps without losing a line', () => {
const store = new EditHistoryStore('s1', { dir });
const first = jsonl([editLine('e0', 0)]);
syncForward(store, first);
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
const file = Buffer.concat([first, jsonl(more)]);
const budget = 700;
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
expect(store.list()).toHaveLength(31);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
});
it('skips a line longer than the budget instead of re-reading it forever', () => {
const store = new EditHistoryStore('s1', { dir });
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
const first = jsonl([editLine('e0', 0)]);
syncForward(store, first);
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
expect(ids(store)).toEqual(['e2', 'e0']);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
});
it('reads the line after a skipped one whole, wherever it falls against the chunk grid', async () => {
const budget = 1000;
const first = jsonl([editLine('e0', 0)]);
const lost: number[] = [];
for (let pad = 0; pad < budget; pad++) {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, first);
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2400 + pad) } });
const next = editLine('next', 2, '/work/next.ts', { pad: 'p'.repeat(300) }); // < budget
const file = Buffer.concat([first, jsonl([huge, next, editLine('e3', 3)])]);
for (let i = 0; i < 50 && syncForward(store, file, budget); i++);
if (!store.get('next') || !store.get('e3')) lost.push(pad);
await store.remove();
}
expect(lost).toEqual([]);
});
it('keeps a multibyte character split by the chunk boundary intact', () => {
const store = new EditHistoryStore('s1', { dir });
const path = '/work/Größe – Ümlaut 😀.ts';
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
const emoji = file.indexOf(Buffer.from('😀'));
// Cut inside the 4-byte emoji, then continue.
const cut = file.subarray(0, emoji + 2);
syncForward(store, cut);
syncForward(store, file);
expect(store.get('e2')?.path).toBe(path);
});
it('restarts a file that shrank (replaced) from its end, with the next read', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
const replaced = jsonl([editLine('n1', 3)]);
// The reader clamps the cursor to the size: an empty chunk at the end, and the cursor goes.
expect(syncForward(store, replaced)?.added).toBe(0);
expect(store.getCursor(MAIN_SOURCE)).toBeUndefined();
expect(syncForward(store, replaced)?.added).toBe(1);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
});
it('ignores a chunk that does not start at the cursor', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
syncForward(store, file);
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
expect(res.added).toBe(0);
});
});
describe('EditHistoryStore: conversations and sources', () => {
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1)]));
const second = jsonl([editLine('f1', 5)]);
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
expect(store.getTranscriptId()).toBe('conv-2');
expect(ids(store)).toEqual(['f1', 'e1']);
});
it('keeps one cursor per source and the subagent id on its entries', () => {
const store = new EditHistoryStore('s1', { dir });
const main = jsonl([editLine('m1', 1)]);
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
const subSource = 'subagents/agent-a1.jsonl';
syncForward(store, main);
syncForward(store, sub, undefined, subSource);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
expect(store.get('s1')?.agentId).toBe('a1');
});
it('records agent meta from meta.json text', () => {
const store = new EditHistoryStore('s1', { dir });
store.setAgentMeta(
'a1',
JSON.stringify({
agentType: 'coding',
description: 'Impl: fix',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
toolUseId: 'toolu_x',
})
);
store.setAgentMeta('a2', 'not json');
expect(store.getAgents()).toEqual({
a1: {
description: 'Impl: fix',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
},
});
expect(store.hasAgentMeta('a2')).toBe(false);
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
});
});
describe('EditHistoryStore: older blocks', () => {
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
const budget = Math.floor(file.length / 4);
syncForward(store, file, budget);
const before = store.list().length;
expect(before).toBeLessThan(30);
let rounds = 0;
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
expect(rounds).toBeGreaterThan(1);
expect(store.list()).toHaveLength(30);
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
expect(store.backfillTruncated).toBe(false);
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
});
it('skips a line longer than the budget going back and reaches the lines before it', () => {
const store = new EditHistoryStore('s1', { dir });
const huge = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(6000) } });
const older = Array.from({ length: 10 }, (_, i) => editLine(`old${i}`, i));
const newer = Array.from({ length: 5 }, (_, i) => editLine(`new${i}`, 100 + i));
const file = jsonl([...older, huge, ...newer]);
const budget = 4000;
syncForward(store, file, budget);
expect(ids(store).sort()).toEqual(newer.map((_, i) => `new${i}`).sort());
let rounds = 0;
while (syncOlder(store, file, budget) && rounds < 30) rounds++;
expect(store.list()).toHaveLength(15);
expect(store.getCursor(MAIN_SOURCE)).toMatchObject({ floor: 0 });
expect(store.getCursor(MAIN_SOURCE)?.floorMidLine).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
});
it('going back, reads the line before a skipped one whole, wherever it falls against the block grid', async () => {
const budget = 1000;
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2500) } });
const lost: number[] = [];
for (let pad = 0; pad < budget; pad++) {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
const lines = [editLine('p0', 0), editLine('p1', 1), editLine('before', 2), huge];
for (let i = 0; i < 6; i++)
lines.push(editLine(`t${i}`, 10 + i, `/work/t${i}.ts`, i === 0 ? { pad: 'p'.repeat(pad) } : {}));
const file = jsonl(lines);
syncForward(store, file, budget);
for (let i = 0; i < 50 && syncOlder(store, file, budget); i++);
if (!store.get('before') || !store.get('p0')) lost.push(pad);
await store.remove();
}
expect(lost).toEqual([]);
});
it('a source whose file is gone (dropSource) offers no older lines and keeps its entries', () => {
const store = new EditHistoryStore('s1', { dir });
const sub = 'subagents/agent-a1.jsonl';
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`s${i}`, i, `/work/s${i}.ts`, { agentId: 'a1' })));
syncForward(store, file, 400, sub);
expect(store.sourcesWithOlder()).toEqual([sub]);
const kept = ids(store);
store.dropSource(sub);
expect(store.getCursor(sub)).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
expect(ids(store)).toEqual(kept);
});
it('refuses an older chunk that does not end at the floor', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
syncForward(store, file, 300);
const res = store.applyOlder({
transcriptId: 'conv-1',
source: MAIN_SOURCE,
startOffset: 0,
chunk: file.subarray(0, 10),
size: file.length,
});
expect(res.added).toBe(0);
});
});
describe('EditHistoryStore: Bash images', () => {
it('pairs a Bash call and its result across a chunk boundary', () => {
const store = new EditHistoryStore('s1', { dir });
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
expect(syncForward(store, part1)?.candidates).toEqual([]);
const res = syncForward(store, file)!;
expect(res.candidates).toEqual([
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
]);
});
it('adds confirmed images as entries without a diff, once', () => {
const store = new EditHistoryStore('s1', { dir });
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
expect(store.addImageEntries([img, img])).toBe(1);
expect(store.addImageEntries([img])).toBe(0);
expect(store.list()[0]).toEqual({
id: 'b1:/work/out/frame.png',
ts: T0 + 4000,
path: '/work/out/frame.png',
kind: 'image',
tool: 'bash',
created: false,
added: 0,
removed: 0,
hunks: [],
truncated: false,
agentId: 'a1',
});
});
});
describe('EditHistoryStore: cap', () => {
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
const res = syncForward(store, file, file.length);
expect(res?.added).toBe(MAX_ENTRIES);
expect(store.list()).toHaveLength(MAX_ENTRIES);
expect(store.get('e19')).toBeUndefined();
expect(store.get('e20')).toBeDefined();
// Older than everything kept: counts as nothing added.
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
});
it('offers no older block once the cap is full (it would be dropped on read)', () => {
const store = new EditHistoryStore('s1', { dir });
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
const file = jsonl(lines);
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
syncForward(store, file, tail + 10);
expect(store.list()).toHaveLength(MAX_ENTRIES);
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
expect(store.backfillTruncated).toBe(false);
});
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
const store = new EditHistoryStore('s1', { dir });
const n = 300;
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
syncForward(store, file, file.length);
const kept = store.list();
expect(kept.length).toBeLessThan(n);
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
expect(store.get(`e${n - 1}`)).toBeDefined();
expect(store.get('e0')).toBeUndefined();
});
it('offers no older block once the byte cap is full', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
syncForward(store, file, 5_000_000);
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
expect(store.backfillTruncated).toBe(false);
});
it('caps the agent meta strings', () => {
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
expect(meta?.agentType).toBe('coding');
});
it(`keeps at most ${MAX_AGENTS} subagent labels, dropping the oldest no entry uses`, () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1, '/work/a.ts', { agentId: 'a0' })]));
for (let i = 0; i <= MAX_AGENTS + 4; i++) store.setAgentMeta(`a${i}`, `{"description":"d${i}"}`);
const agents = store.getAgents();
expect(Object.keys(agents)).toHaveLength(MAX_AGENTS);
expect(agents.a0).toBeDefined(); // used by an entry
expect(agents[`a${MAX_AGENTS + 4}`]).toBeDefined(); // the newest
expect(agents.a1).toBeUndefined();
});
});
describe('EditHistoryStore: persistence', () => {
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
syncForward(store, file);
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
await store.flush();
const loaded = await EditHistoryStore.load('s1', { dir });
expect(ids(loaded)).toEqual(['e1']);
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
expect(loaded.getTranscriptId()).toBe('conv-1');
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
});
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
const sub = join(dir, 'edit-history');
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
expect(statSync(sub).mode & 0o777).toBe(0o700);
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
await store.flush();
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
});
it('applies the caps and drops malformed entries when loading', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
await store.flush();
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
data.entries.push(
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
{ id: 'broken', path: 7, hunks: [] },
{ id: 'nohunks', path: '/a.ts' },
extra
);
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
const loaded = await EditHistoryStore.load('s1', { dir });
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
expect(loaded.get('newest')).toBeDefined();
expect(loaded.get('e0')).toBeUndefined();
expect(loaded.get('broken')).toBeUndefined();
expect(loaded.get('nohunks')).toBeUndefined();
});
it('drops broken cursors, pending Bash calls and agent labels when loading (a damaged file)', async () => {
writeFileSync(
join(dir, 's1.json'),
JSON.stringify({
version: 1,
sessionId: 's1',
transcriptId: 'conv-1',
cursors: {
main: { offset: 1.5, floor: 0 },
'subagents/agent-x.jsonl': { offset: -5, floor: 0 },
'subagents/agent-y.jsonl': { offset: 10, floor: 20 },
'subagents/agent-z.jsonl': { offset: 10, floor: 5, floorMidLine: true },
},
pendingBash: [null, 7, { id: 'b1', ts: 1, command: 'render > a.png', cwd: '/work' }],
agents: { x: null, y: 'text', z: { description: 'd', agentType: 'coding' } },
entries: [{ id: 'e1', ts: 1, path: '/a.ts', hunks: [null] }],
})
);
const loaded = await EditHistoryStore.load('s1', { dir });
expect(loaded.knownSources()).toEqual(['subagents/agent-z.jsonl']);
expect(loaded.getCursor('subagents/agent-z.jsonl')).toEqual({ offset: 10, floor: 5, floorMidLine: true });
expect(loaded.getAgents()).toEqual({ z: { description: 'd', agentType: 'coding' } });
expect(loaded.list()).toEqual([]);
// The surviving pending call still pairs with its result, and nothing throws.
expect(syncForward(loaded, jsonl([bashResult('b1', 3)]))?.candidates[0]?.paths).toEqual(['/work/a.png']);
});
it('rebuilds loaded entries under the same bounds: known fields only, string diff lines, the per-edit cap', async () => {
const good = {
ts: 1,
path: '/a.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 1,
removed: 0,
truncated: false,
};
writeFileSync(
join(dir, 's1.json'),
JSON.stringify({
version: 1,
sessionId: 's1',
transcriptId: 'conv-1',
cursors: {},
pendingBash: [
{ id: 'b1', ts: 1, command: 'ls', cwd: '/work' },
{ id: 'b2', ts: 1, command: 'ls', cwd: '/' + 'c'.repeat(5000) },
{ id: 'p'.repeat(5000), ts: 1, command: 'ls', cwd: '/work' },
],
agents: { ['a'.repeat(MAX_AGENT_META_CHARS + 1)]: { description: 'd', agentType: 't' } },
entries: [
{ ...good, id: 'extra', hunks: [], blob: 'x'.repeat(1000) },
{ ...good, id: 'objlines', hunks: [{ oldStart: 1, lines: [{ k: 'y' }, '+kept'] }] },
{ ...good, id: 'bighunk', hunks: [{ lines: ['+' + 'q'.repeat(MAX_HUNK_BYTES)] }] },
{ ...good, id: 'badtool', tool: 'Rm', hunks: [] },
],
})
);
const loaded = await EditHistoryStore.load('s1', { dir, debounceMs: 60_000 });
expect(
loaded
.list()
.map((e) => e.id)
.sort()
).toEqual(['bighunk', 'extra', 'objlines']);
expect(loaded.get('extra')).not.toHaveProperty('blob');
expect(loaded.get('objlines')!.hunks[0].lines).toEqual(['+kept']);
expect(loaded.get('bighunk')).toMatchObject({ truncated: true, hunks: [{ lines: [] }] });
expect(loaded.getAgents()).toEqual({});
await loaded.flush();
expect(
JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8')).pendingBash.map((p: { id: string }) => p.id)
).toEqual(['b1']);
});
it("counts an entry's id against the byte cap", () => {
const e = {
id: 'i'.repeat(1000),
ts: 1,
path: '/a.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 0,
removed: 0,
hunks: [],
truncated: false,
} as const;
expect(entryBytes(e) - entryBytes({ ...e, id: 'i' })).toBe(999);
});
it(`does not parse a store file larger than ${MAX_FILE_BYTES} bytes`, async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
const text = readFileSync(join(dir, 's1.json'), 'utf-8');
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
writeFileSync(join(dir, 's1.json'), text + ' '.repeat(MAX_FILE_BYTES)); // still valid JSON
expect((await EditHistoryStore.load('s1', { dir })).list()).toEqual([]);
});
it('sweep() deletes the histories of sessions that are gone and stale tmp files, nothing else', async () => {
for (const name of [
'live.json',
'gone.json',
`gone.json.${process.pid + 1}.tmp`,
// An earlier run that had this process's pid (a container's pid 1): stale as well.
`live.json.${process.pid}.tmp`,
`live.json.${process.pid}-0a1b2c3d.tmp`,
`live.json.${TMP_TAG}.tmp`,
'notes.txt',
]) {
writeFileSync(join(dir, name), '{}');
}
expect(await EditHistoryStore.sweep((id) => id === 'live', { dir })).toBe(1);
expect(readdirSync(dir).sort()).toEqual([`live.json.${TMP_TAG}.tmp`, 'live.json', 'notes.txt'].sort());
expect(await EditHistoryStore.sweep(() => false, { dir: join(dir, 'missing') })).toBe(0);
});
it('takes only a plain transcript id from the file (the sync builds a path from it)', async () => {
const cursors = { main: { offset: 10, floor: 0 } };
for (const transcriptId of ['../../etc/passwd', 'a/b', '', 7]) {
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId, cursors, entries: [] }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBeNull();
expect(store.knownSources()).toEqual([]); // offsets into no known conversation
}
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId: 'conv-1.a_b', cursors }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBe('conv-1.a_b');
expect(store.knownSources()).toEqual(['main']);
});
it('settle() writes pending changes and nothing for a store without any', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.settle();
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
const loaded = await EditHistoryStore.load('s1', { dir });
rmSync(join(dir, 's1.json'));
await loaded.settle();
await new EditHistoryStore('empty', { dir }).settle();
expect(readdirSync(dir)).toEqual([]);
});
it('delete() removes a stored history without parsing it', async () => {
writeFileSync(join(dir, 's1.json'), 'not json');
await EditHistoryStore.delete('s1', { dir });
await EditHistoryStore.delete('missing', { dir });
expect(readdirSync(dir)).toEqual([]);
});
it('ignores a file of an unknown version', async () => {
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
});
it('writes debounced', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
syncForward(store, jsonl([editLine('e1', 1)]));
const path = join(dir, 's1.json');
expect(existsSync(path)).toBe(false);
await new Promise((r) => setTimeout(r, 80));
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
});
it('starts empty from a missing or corrupt file', async () => {
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
const store = new EditHistoryStore('bad', { dir });
await store.flush();
writeFileSync(join(dir, 'bad.json'), '{broken');
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
});
it('remove() deletes the file and stops later writes', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
await store.remove();
expect(existsSync(join(dir, 's1.json'))).toBe(false);
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
await new Promise((r) => setTimeout(r, 40));
await store.flush();
expect(existsSync(join(dir, 's1.json'))).toBe(false);
expect(store.list()).toEqual([]); // the content is forgotten too
});
it('remove() during a write leaves nothing behind (the write cannot rename over the deleted file)', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
const writing = store.flush();
// Let the write get past its first check, so remove() meets it under way.
await new Promise((r) => setImmediate(r));
await Promise.all([writing, store.remove()]);
expect(readdirSync(dir)).toEqual([]);
});
it('defaults to the data directory', () => {
const store = new EditHistoryStore('s1');
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
});
});
+699
View File
@@ -0,0 +1,699 @@
/**
* Edit history sync: reader, store, image confirmation, single-flight, failure states and the
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
import type {
EditSourceReadOptions,
EditSourceRequest,
EditSources,
PathProbe,
} from '../src/edit-history-local-source.js';
import {
confirmImages,
EditHistorySync,
MAX_LOADED_STORES,
MAX_OLDER_SOURCES,
NO_TRANSCRIPT,
REMOVED,
type EditHistorySession,
type EditHistorySyncDeps,
unsupportedReason,
} from '../src/web/edit-history-sync.js';
let dir: string;
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
const opened: EditHistoryStore[] = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-sync-'));
});
afterEach(async () => {
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(dir, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
},
...extra,
}) + '\n'
);
}
function bashPair(id: string, start: number, end: number, command: string): string {
const use = JSON.stringify({
type: 'assistant',
timestamp: new Date(start).toISOString(),
cwd: '/w',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
});
const res = JSON.stringify({
type: 'user',
timestamp: new Date(end).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
});
return use + '\n' + res + '\n';
}
/** An in-memory transcript: files by relPath, served with the reader contract. */
class FakeHost {
transcriptId = 'conv1';
files = new Map<string, Buffer>();
metas: Record<string, string> = {};
calls: EditSourceRequest[][] = [];
paths: string[] = [];
/** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */
get path(): string | null {
return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null;
}
read = async (
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> => {
this.calls.push([...requests]);
this.paths.push(mainPath);
if (this.files.size === 0) return null;
let remaining = budget;
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
for (const [relPath, buf] of this.files) {
const req = requests.find((r) => r.relPath === relPath);
if (!req && opts.onlyRequested) continue;
let start: number;
let len: number;
if (req?.len !== undefined) {
const blockEnd = Math.min(req.offset + req.len, buf.length);
len = Math.min(blockEnd - Math.min(req.offset, buf.length), remaining);
start = blockEnd - len;
} else if (req) {
start = Math.min(req.offset, buf.length);
len = Math.min(remaining, buf.length - start);
} else {
len = Math.min(buf.length, remaining);
start = buf.length - len;
const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1];
if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent];
}
remaining -= len;
out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) });
}
return out;
};
append(relPath: string, text: string): void {
this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)]));
}
}
function setup(over: Partial<EditHistorySyncDeps> = {}) {
const host = new FakeHost();
const probes = new Map<string, PathProbe>();
const lookups: string[] = [];
/** Session ids in the order their stores were loaded. */
const loads: string[] = [];
const deps: EditHistorySyncDeps = {
transcriptPath: async (session) => {
lookups.push(session.id);
return host.path;
},
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...over,
};
const load = deps.loadStore;
deps.loadStore = async (id) => {
loads.push(id);
const store = await load(id);
opened.push(store);
return store;
};
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
}
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
const remote: EditHistorySession = {
id: 's2',
mode: 'claude',
remote: { host: 'huff' } as EditHistorySession['remote'],
claudeSessionId: 'conv1',
};
describe('unsupportedReason', () => {
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
expect(unsupportedReason(local)).toBeUndefined();
expect(unsupportedReason(remote)).toBe('remote');
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
});
});
describe('EditHistorySync', () => {
it('reads new edits forward from the stored offset', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
await sync.sync(local);
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']);
expect(host.calls[1]).toEqual([
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length },
]);
expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']);
});
it('reads subagent transcripts and records their meta', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' }));
host.metas.a1 = '{"description":"Fix it","agentType":"coding"}';
await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.get('t9')?.agentId).toBe('a1');
expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' });
});
it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
host.transcriptId = 'conv2';
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
await sync.sync(local);
expect(host.paths.at(-1)).toBe('/p/conv2.jsonl');
expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file
const store = await sync.getStore('s1');
expect(store.getTranscriptId()).toBe('conv2');
expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']);
});
it('keeps reading in passes when a file grew by more than one budget', async () => {
const { host, sync } = setup({ budget: 600 });
host.append('main', editLine('t0', T0, '/w/z.ts'));
await sync.sync(local);
for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
await sync.sync(local);
expect((await sync.getStore('s1')).list()).toHaveLength(4);
});
it('turns a Bash image into an entry only when its mtime lies in the call window', async () => {
const { host, probes, sync } = setup();
host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png'));
probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 });
probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 });
probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 });
await sync.sync(local);
expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([
['/w/out/new.png', 'bash', 'image'],
['/home/u/x.png', 'bash', 'image'],
]);
});
it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => {
const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null));
let removal: Promise<void> | undefined;
const ctx = setup({
probe,
readSources: async (path, req, budget, opts) => {
const res = await ctx.host.read(path, req, budget, opts);
removal = ctx.sync.remove('s1'); // the session is deleted while this read is out
return res;
},
});
ctx.host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/a.png'));
ctx.host.append('subagents/agent-a1.jsonl', bashPair('b2', T0, T0 + 5000, 'render out/b.png'));
await ctx.sync.sync(local);
await removal;
expect(probe).not.toHaveBeenCalled();
});
it('keeps the edits when only the image probe fails', async () => {
const { host, sync } = setup({
probe: async () => {
throw new Error('probe failed');
},
});
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
});
it('reports a session without a transcript yet, then reads it once there is one', async () => {
const { host, sync } = setup();
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
expect(host.calls).toEqual([]);
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('reports a transcript that vanished (watcher path stale) the same way', async () => {
const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' });
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
expect(host.paths).toEqual(['/p/gone.jsonl']);
});
it('reports a read error as unavailable and keeps the stored entries', async () => {
const { host, sync, deps } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
deps.readSources = async () => {
throw new Error('EACCES: permission denied');
};
const status = await sync.sync(local);
expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
const { host, lookups, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
expect(lookups).toEqual([]);
expect(host.calls).toEqual([]);
});
it('runs one sync per session at a time and folds occasions into one more run', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local), sync.sync(local)];
release();
await Promise.all(runs);
expect(host.calls).toHaveLength(2); // the first run + one folded follow-up
});
it('loads older blocks on request', async () => {
const { host, sync } = setup({ budget: 400 });
for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
await sync.sync(local);
const store = await sync.getStore('s1');
const before = store.list().length;
expect(store.backfillTruncated).toBe(true);
await sync.sync(local, { older: true });
expect(store.list().length).toBeGreaterThan(before);
});
it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => {
const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
await sync.flushAll();
const reloaded = await EditHistoryStore.load('s1', { dir });
expect(reloaded.list().map((e) => e.id)).toEqual(['t1']);
});
it('remove deletes the stored history', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
await (await sync.getStore('s1')).flush();
await sync.remove('s1');
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
});
it('neither a run in flight nor a request during the removal writes the deleted history again', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
let entered!: () => void;
const reading = new Promise<void>((r) => (entered = r));
const orig = host.read;
host.read = async (...a) => {
entered();
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
await reading;
const removing = sync.remove('s1');
// The removal waits for the read under way; a request meanwhile reads nothing.
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
release();
await Promise.all([...runs, removing]);
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
expect(readdirSync(dir)).toEqual([]);
expect(host.calls).toHaveLength(1); // the read under way; the queued run never read
});
it('the same conversation under an unnormalized path reads on from its cursors, nothing else', async () => {
const path = '/p/./conv1.jsonl';
const { host, sync } = setup({ transcriptPath: async () => path });
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
host.append('main', editLine('t2', T0 + 1, '/w/a.ts'));
await sync.sync(local);
expect(host.paths).toEqual([path, path]);
expect(host.calls[1]).toEqual([
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1, '/w/a.ts').length },
]);
});
it('a session that comes back with the same id (reboot restore) reads its history afresh', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
// Boot: the unpinned record is pruned as stale; then "restore" brings the session back.
await sync.remove('s1');
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
});
it('remove deletes a history it has not loaded without loading it', async () => {
const { sync, loads } = setup();
const other = new EditHistoryStore('s1', { dir });
other.addImageEntries([{ toolUseId: 'b1', path: '/w/a.png', ts: T0 }]);
await other.flush();
await sync.remove('s1');
expect(loads).toEqual([]);
expect(existsSync(join(dir, 's1.json'))).toBe(false);
});
it(`keeps at most ${MAX_LOADED_STORES} stores loaded: a dropped one is written and loads again whole`, async () => {
const { host, sync, loads } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
host.append('main', editLine('t1', T0, '/w/a.ts'));
const ids = Array.from({ length: MAX_LOADED_STORES + 2 }, (_, i) => `s${i}`);
for (const id of ids) await sync.sync({ ...local, id });
expect(loads).toEqual(ids);
// The two least recently used went: written (no debounced save ran), not kept in memory.
await sync.flushAll();
expect(existsSync(join(dir, 's0.json'))).toBe(true);
expect((await sync.getStore('s0')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.slice(ids.length)).toEqual(['s0']);
// The most recently used ones stay loaded.
await sync.getStore(ids[ids.length - 1]);
expect(loads.slice(ids.length)).toEqual(['s0']);
});
it('a dropped store that never changed writes nothing (no file for a session without edits)', async () => {
const { sync } = setup(); // no transcript: nothing to read
for (let i = 0; i <= MAX_LOADED_STORES; i++) await sync.sync({ ...local, id: `s${i}` });
await sync.flushAll();
expect(readdirSync(dir)).toEqual([]);
});
it('a store whose run is in flight is not dropped, and loads once', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { host, sync, loads } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
const orig = host.read;
let first = true;
let inRead!: () => void;
const reachedRead = new Promise<void>((r) => (inRead = r));
host.read = async (...a) => {
if (first) {
first = false;
inRead();
await gate;
}
return orig(...a);
};
const slow = sync.sync(local);
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
// could reach the read first, take the gate and hang the loop below before `release()`.
await reachedRead;
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
release();
expect(await slow).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.filter((id) => id === 's1')).toEqual(['s1']);
});
it('concurrent first callers share one store', async () => {
const { sync } = setup();
const [a, b] = await Promise.all([sync.getStore('s1'), sync.getStore('s1')]);
expect(a).toBe(b);
});
it('a forward request queued together with an older one still reads forward', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
const res = await orig(...a); // the first run's bytes are taken before the append
await gate;
return res;
};
const first = sync.sync(local);
await new Promise((r) => setTimeout(r, 0));
host.append('main', editLine('t2', T0 + 1, '/w/b.ts'));
const queued = [sync.sync(local), sync.sync(local, { older: true })];
release();
await Promise.all([first, ...queued]);
expect((await sync.getStore('s1')).get('t2')).toBeDefined();
});
it('load older moves every source back, reading only the blocks it asked for', async () => {
const { host, sync } = setup({ budget: 2000 });
for (let i = 0; i < 10; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
for (let i = 0; i < 10; i++) {
host.append('subagents/agent-ab.jsonl', editLine(`s${i}`, T0 + 100 + i, `/w/s${i}.ts`, { agentId: 'ab' }));
}
await sync.sync(local);
await sync.sync(local); // the subagent gets its first block once main is up to date
const store = await sync.getStore('s1');
const floors = () => [store.getCursor('main')!.floor, store.getCursor('subagents/agent-ab.jsonl')!.floor];
const [main0, sub0] = floors();
expect(main0).toBeGreaterThan(0);
expect(sub0).toBeGreaterThan(0);
await sync.sync(local, { older: true });
const [main1, sub1] = floors();
expect(main1).toBeLessThan(main0);
expect(sub1).toBeLessThan(sub0);
expect(
host.calls
.at(-1)!
.map((r) => r.relPath)
.sort()
).toEqual(['main', 'subagents/agent-ab.jsonl']);
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.list()).toHaveLength(20);
});
it('a file the shared budget cut short is read in the next pass, not skipped as one long line', async () => {
const budget = 4000;
const { host, sync } = setup({ budget });
const sub = 'subagents/agent-a1.jsonl';
host.append('main', editLine('m0', T0, '/w/m0.ts'));
host.append(sub, editLine('s0', T0 + 1, '/w/s0.ts', { agentId: 'a1' }));
await sync.sync(local);
// main grows by almost a whole budget, leaving the subagent 50 bytes of this pass
const filler = JSON.stringify({ type: 'user', message: { content: '' } });
host.append(
'main',
JSON.stringify({ type: 'user', message: { content: 'z'.repeat(budget - 51 - filler.length) } }) + '\n'
);
host.append(sub, editLine('s1', T0 + 2, '/w/s1.ts', { agentId: 'a1', pad: 'p'.repeat(100) }));
host.append(sub, editLine('s2', T0 + 3, '/w/s2.ts', { agentId: 'a1' }));
await sync.sync(local);
const store = await sync.getStore('s1');
expect(
store
.list()
.map((e) => e.id)
.sort()
).toEqual(['m0', 's0', 's1', 's2']);
expect(store.getCursor(sub)?.midLine).toBeUndefined();
});
it('a first read reaches the subagents in a further pass when main fills the budget', async () => {
const { host, sync } = setup({ budget: 3000 });
for (let i = 0; i < 20; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
host.append('subagents/agent-a1.jsonl', editLine('s0', T0 + 100, '/w/s0.ts', { agentId: 'a1' }));
await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.get('s0')?.agentId).toBe('a1');
expect(store.getCursor('subagents/agent-a1.jsonl')).toBeDefined();
});
it('load older reads a full budget per source: a line longer than the budget split over the sources is kept', async () => {
const budget = 2000;
const { host, sync } = setup({ budget });
host.append('main', editLine('m0', T0, '/w/m0.ts'));
host.append('main', editLine('big', T0 + 1, '/w/big.ts', { pad: 'p'.repeat(800) })); // > budget / 3
for (let i = 2; i < 12; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
for (const a of ['a1', 'a2']) {
for (let i = 0; i < 12; i++) {
host.append(
`subagents/agent-${a}.jsonl`,
editLine(`${a}-${i}`, T0 + 100 + i, `/w/${a}${i}.ts`, { agentId: a })
);
}
}
for (let i = 0; i < 3; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.sourcesWithOlder()).toHaveLength(3);
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.get('big')).toBeDefined();
expect(store.list()).toHaveLength(36);
});
it(`load older reads at most ${MAX_OLDER_SOURCES} sources per click, taking turns`, async () => {
const { host, sync } = setup({ budget: 600 });
const subs = ['a1', 'a2', 'a3', 'a4'].map((a) => `subagents/agent-${a}.jsonl`);
for (const relPath of ['main', ...subs]) {
for (let i = 0; i < 8; i++) host.append(relPath, editLine(`${relPath}-${i}`, T0 + i, `/w/${i}.ts`));
}
for (let i = 0; i < 3; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.sourcesWithOlder()).toHaveLength(5);
const olderCall = () => host.calls.at(-1)!.map((r) => r.relPath);
await sync.sync(local, { older: true });
expect(olderCall()).toEqual(['main', subs[0], subs[1]]);
await sync.sync(local, { older: true });
expect(olderCall()).toEqual([subs[2], subs[3], 'main']);
for (let i = 0; i < 30 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.list()).toHaveLength(40);
});
it('a subagent transcript that vanished loses its cursor (no endless "Load older"), not its entries', async () => {
const { host, sync } = setup({ budget: 600 });
const sub = 'subagents/agent-a1.jsonl';
host.append('main', editLine('m0', T0, '/w/m0.ts'));
for (let i = 0; i < 8; i++) host.append(sub, editLine(`s${i}`, T0 + 10 + i, `/w/s${i}.ts`, { agentId: 'a1' }));
for (let i = 0; i < 2; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.backfillTruncated).toBe(true);
const kept = store.list().length;
host.files.delete(sub);
await sync.sync(local, { older: true });
expect(store.getCursor(sub)).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
expect(store.list()).toHaveLength(kept);
});
it('sweep keeps the sessions in use here and those that exist', async () => {
const asked: Record<string, boolean> = {};
const { sync } = setup({
sweepStores: async (keep) => {
for (const id of ['s1', 'kept', 'gone']) asked[id] = keep(id);
return 1;
},
});
await sync.getStore('s1');
expect(await sync.sweep((id) => id === 'kept')).toBe(1);
expect(asked).toEqual({ s1: true, kept: true, gone: false });
});
});
describe('confirmImages', () => {
it('ignores directories and missing files', () => {
const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }];
const out = confirmImages(c, (p) =>
p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null
);
expect(out).toEqual([]);
});
it('lists a file sent to the user outside any window, once per file version', () => {
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 };
const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true };
expect(confirmImages([sent], () => png)).toEqual([
{ toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' },
]);
// The Bash call that wrote it was already listed: same version, not again.
expect(
confirmImages(
[sent],
() => png,
() => T0 + 500
)
).toEqual([]);
expect(confirmImages([sent], () => null)).toEqual([]);
});
it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => {
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 };
const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] };
const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] };
expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']);
// A Write entry for the same path at T0 + 950 already covers it.
expect(
confirmImages(
[ls],
() => png,
() => T0 + 950
)
).toEqual([]);
// A later re-render (newer mtime, outside the slack) is a new version.
const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] };
const newer = { ...png, mtimeMs: T0 + 10_500 };
expect(
confirmImages(
[again],
() => newer,
() => T0 + 1000
).map((i) => i.toolUseId)
).toEqual(['r2']);
});
});
describe('reads only on request (wiring guard)', () => {
const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8');
it('the server never starts a sync itself: it only hands the reader to the routes', () => {
const server = read('src/web/server.ts');
const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? '');
expect(uses.sort()).toEqual(['', '.flushAll', '.remove', '.sweep']);
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
});
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
const root = join(__dirname, '..');
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
.filter((f) => /\.(ts|js)$/.test(f))
.map((f) => join('src', f).split('\\').join('/'))
.filter((f) => /EditHistorySync|editHistorySync/.test(read(f)));
expect(holders.sort()).toEqual([
'src/web/edit-history-sync.ts',
'src/web/routes/edit-history-routes.ts',
'src/web/server.ts',
]);
for (const file of [
'src/web/edit-history-sync.ts',
'src/edit-history-store.ts',
'src/edit-history-local-source.ts',
'src/edit-history-parse.ts',
'src/web/routes/edit-history-routes.ts',
]) {
const src = read(file);
expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/);
}
// The store's only timer is its debounced save.
expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/);
});
});
vi.setConfig({ testTimeout: 10_000 });
+483
View File
@@ -0,0 +1,483 @@
/**
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
* events the open panel follows go through the real server.
*/
import { homedir } from 'node:os';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page, type Route } from 'playwright';
import { WebServer } from '../src/web/server.js';
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
'base64'
);
type Entry = {
id: string;
ts: number;
path: string;
relPath: string;
kind: 'code' | 'image';
tool: string;
created: boolean;
added: number;
removed: number;
agentId?: string;
};
describe('Edit history panel in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let root: string;
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
let ext: string;
let sessionA: string;
let sessionB: string;
const settingsPutStatuses: number[] = [];
/** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */
const attachmentPosts: unknown[] = [];
/** Every request to the mocked API: `METHOD url`. */
const calls: string[] = [];
/** The mocked server state per session id. */
const state = new Map<string, Record<string, unknown>>();
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
let olderEntries: Entry[] = [];
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
ts: Date.now(),
path: join(root, over.relPath),
kind: 'code',
tool: 'Edit',
created: false,
added: 1,
removed: 1,
...over,
});
const json = (route: Route, data: unknown) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
const buttonVisible = () =>
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
const setSetting = async (on: boolean) => {
await page.evaluate(() => (window as any).app.openAppSettings());
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
};
const createSession = (dir: string) =>
page.evaluate(async (workingDir) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id as string;
}, dir);
const select = (id: string) =>
page.evaluate(async (sid) => {
const app = (window as any).app;
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
await app.selectSession(sid);
}, id);
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
const toolEnd = (sessionId: string, toolName: string) =>
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
beforeAll(async () => {
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
writeFileSync(join(root, 'shot.png'), PNG);
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
writeFileSync(join(ext, 'render.png'), PNG);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
page.on('request', (r) => {
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
attachmentPosts.push(r.postDataJSON());
});
await page.route('**/api/sessions/*/edits**', async (route) => {
const req = route.request();
const url = new URL(req.url());
calls.push(`${req.method()} ${url.pathname}${url.search}`);
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
const sid = decodeURIComponent(m[1]);
const rest = m[2] ? decodeURIComponent(m[2]) : '';
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
if (req.method() === 'POST' && rest === 'older') {
const entries = [...(s.entries as Entry[]), ...olderEntries];
state.set(sid, { ...s, entries, backfillTruncated: false });
return json(route, state.get(sid));
}
if (req.method() === 'GET' && rest) {
const d = diffs[rest];
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
return json(route, d);
}
return json(route, s);
});
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
sessionA = await createSession(root);
sessionB = await createSession(root);
await select(sessionA);
const t0 = Date.now();
state.set(sessionA, {
state: 'ok',
backfillTruncated: true,
agents: {
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
},
entries: [
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
entry({
id: 'e2',
ts: t0 - 2000,
relPath: 'src/b.ts',
created: true,
tool: 'Write',
added: 10,
removed: 0,
agentId: 'agent1',
}),
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
],
});
diffs = {
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
};
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
}, 120000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(root, { recursive: true, force: true });
rmSync(ext, { recursive: true, force: true });
}, 60000);
it('is off by default: no button, and nothing asks the edits API', async () => {
await page.waitForTimeout(1000);
expect(await buttonVisible()).toBe(false);
expect(calls).toEqual([]);
});
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
await setSetting(true);
expect(settingsPutStatuses.length).toBeGreaterThan(0);
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
expect(await buttonVisible()).toBe(true);
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
});
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel.visible');
await page.waitForSelector('#editHistoryBody .edit-history-entry');
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
expect(await rows().count()).toBe(4);
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
// The image row: a symbol instead of a word, no +/− counts.
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
// The subagent chip: its description, and type + branch on hover.
const chip = rows().nth(2).locator('.edit-history-agent');
expect(await chip.textContent()).toBe('Refactor the parser');
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
// The thumbnail is served by the real file-raw route and decodes.
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
expect(await thumb.count()).toBe(1);
expect(await thumb.getAttribute('src')).toBe(
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
);
await page.waitForFunction(
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
);
expect(await page.textContent('#editHistoryCount')).toBe('4');
});
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
await rows().nth(0).locator('.edit-history-spacer').click();
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
);
expect(lines).toEqual([
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
['', ' context\n'],
['git-diff-line--del', '-old line\n'],
['git-diff-line--add', '+new line\n'],
['git-diff-line--add', '+another\n'],
]);
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
await reload();
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
await rows().nth(0).locator('.edit-history-spacer').click();
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
});
it('a path and an image open the shared file preview with the absolute path', async () => {
await page.evaluate(() => {
const w = window as any;
w.__previewed = [];
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]);
});
await rows().nth(2).locator('.edit-history-path').click();
await page.locator('#editHistoryBody img.edit-history-thumb').click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
[join(root, 'src/b.ts'), sessionA],
[join(root, 'shot.png'), sessionA],
]);
// Clicking the path never toggles the row's diff.
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
await page.evaluate(() => {
const w = window as any;
w.app.openFilePreview = w.__openFilePreview;
});
});
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
await page.check('#editHistoryMainOnlyToggle');
expect(await rows().count()).toBe(3);
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
expect(await bodyText()).not.toContain('src/b.ts');
await page.uncheck('#editHistoryMainOnlyToggle');
expect(await rows().count()).toBe(4);
await page.check('#editHistoryGroupToggle');
const groups = page.locator('#editHistoryBody .edit-history-group');
expect(await groups.count()).toBe(3);
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
await page.uncheck('#editHistoryGroupToggle');
});
it('"Load older" asks the server with a POST and shows what it returns', async () => {
const older = page.locator('#editHistoryBody button.edit-history-older');
expect(await older.count()).toBe(1);
await older.click();
await page.waitForFunction(() =>
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
expect(await rows().count()).toBe(5);
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
});
it('while open it follows the session: an edit-capable tool end or a turn end re-reads (debounced), other tools, sessions and keep-alive turns do not', async () => {
const before = syncReads(sessionA);
const s = state.get(sessionA)!;
state.set(sessionA, {
...s,
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
});
toolEnd(sessionA, 'Read');
toolEnd(sessionA, 'Grep');
toolEnd('someone-else', 'Edit');
await page.waitForTimeout(1000);
expect(syncReads(sessionA)).toBe(before);
toolEnd(sessionA, 'Edit');
toolEnd(sessionA, 'Bash');
await page.waitForFunction(() =>
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
await page.waitForTimeout(800);
expect(syncReads(sessionA)).toBe(before + 1);
// The end of a turn re-reads too (a parallel tool call reports one tool end only, a background
// subagent writes later): `session:idle`, since claude's transcript has no `result` line.
// Not another session's, and not the end of Codeman's own keep-alive turn (it edits nothing).
(server as any).broadcast('session:idle', { id: 'someone-else' });
(server as any).broadcast('session:idle', { id: sessionA, keepAlive: true });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before + 1);
(server as any).broadcast('session:idle', { id: sessionA });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before + 2);
});
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
const before = syncReads(sessionA);
toolEnd(sessionA, 'Edit');
(server as any).broadcast('session:idle', { id: sessionA });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before);
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel.visible');
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
expect(syncReads(sessionA)).toBe(before + 1);
});
it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => {
const s = state.get(sessionA)!;
const outside = join(ext, 'render.png');
state.set(sessionA, {
...s,
entries: [
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
...(s.entries as Entry[]),
],
});
await reload();
const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' });
await row.waitFor();
// No thumbnail for it (that would mint an attachment id per render), and no registration.
expect(
await page.evaluate(() =>
[...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src'))
)
).not.toContainEqual(expect.stringContaining('render.png'));
await reload();
expect(attachmentPosts).toEqual([]);
// A click opens the shared preview, which registers the file once, without a notification.
await row.click();
await page.waitForSelector('#filePreviewOverlay.visible');
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
await page.evaluate(() => (window as any).app.closeFilePreview());
state.set(sessionA, s);
});
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
const s = state.get(sessionA)!;
const long =
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
state.set(sessionA, {
...s,
entries: [
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
...(s.entries as Entry[]),
],
});
await reload();
const row = rows().nth(0);
await row.waitFor();
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
const name = row.locator('.edit-history-path-name');
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
// The name is shown whole; only the folder part is shortened.
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
state.set(sessionA, s);
});
it('renders a hostile path as text, never as markup', async () => {
const s = state.get(sessionA)!;
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
state.set(sessionA, {
...s,
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
});
await reload();
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
expect(await page.$('#editHistoryBody script')).toBeNull();
expect(await page.$('#editHistoryBody b')).toBeNull();
expect(
await page.$$eval(
'#editHistoryBody img',
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
)
).toBe(0);
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});
it('unavailable: the saved entries stay, with the reason on top', async () => {
const s = state.get(sessionA)!;
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
await reload();
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
'Showing saved entries only: EACCES: permission denied'
);
expect(await rows().count()).toBeGreaterThan(0);
state.set(sessionA, { ...s, state: 'ok' });
});
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
const s = state.get(sessionA)!;
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
await reload();
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
expect(note).toContain('No transcript found for this session yet');
expect(note).toContain('CLAUDE_CONFIG_DIR');
state.set(sessionA, s);
await reload();
});
it('follows the active session, and says when a session is not a Claude session', async () => {
await select(sessionB);
await page.waitForFunction(() =>
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
expect(await rows().count()).toBe(0);
await select(sessionA);
await page.waitForSelector('#editHistoryBody .edit-history-entry');
});
it('drags by the header', async () => {
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
const box = (await page.locator('.edit-history-header').boundingBox())!;
await page.mouse.move(box.x + 40, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
await page.mouse.up();
const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
expect(after).toBeLessThan(before - 100);
});
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
await setSetting(false);
expect(await buttonVisible()).toBe(false);
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
const before = calls.length;
toolEnd(sessionA, 'Edit');
await page.waitForTimeout(1200);
expect(calls.length).toBe(before);
});
});
+50
View File
@@ -0,0 +1,50 @@
/** Verify parser installation through the real server, including API aliases and base paths. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { WebServer } from '../src/web/server.js';
describe('empty POST: production server wiring', () => {
let server: WebServer;
let app: FastifyInstance;
beforeAll(async () => {
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
await server.start();
app = (server as unknown as { app: FastifyInstance }).app;
});
afterAll(async () => {
await server?.stop();
});
it('accepts empty chunked POSTs to a real API through each supported prefix', async () => {
for (const prefix of ['/api', '/api/v1', '/codeman/api', '/codeman/api/v1']) {
const res = await app.inject({
method: 'POST',
url: `${prefix}/approvals/session/nonexistent/viewed`,
headers: { 'transfer-encoding': 'chunked' },
});
// The real handler ran and found no session, instead of the parser returning 415.
expect(res.statusCode, res.body).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
}
});
it('still rejects a nonempty untyped POST before reaching the same handler', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/approvals/session/nonexistent/viewed',
headers: { 'transfer-encoding': 'chunked' },
payload: '{}',
});
expect(res.statusCode).toBe(415);
});
it('preserves the API 404 envelope for an unknown empty chunked POST', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/nonexistent',
headers: { 'transfer-encoding': 'chunked' },
});
expect(res.statusCode).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
});
});
+11
View File
@@ -51,6 +51,17 @@ describe('envOverrides exact-key allowlist', () => {
).toThrow();
});
it('rejects the shared GitHub token names (Copilot reads COPILOT_GITHUB_TOKEN, in its own prefix)', () => {
for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
for (const mode of ['claude', 'shell', 'codex', 'copilot'] as const) {
expect(() => CreateSessionSchema.parse({ workingDir: '/tmp', mode, envOverrides: { [key]: 'x' } })).toThrow();
}
}
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: { COPILOT_GITHUB_TOKEN: 'x' } })
).not.toThrow();
});
it('still blocks security-sensitive keys', () => {
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
expect(() =>
+534
View File
@@ -0,0 +1,534 @@
/**
* @fileoverview Clickable file paths beyond the rooted absolute form: `~/…`,
* relative paths, and paths a program broke over lines itself.
*
* - `findFilePathLinks` / `resolveLinkedFilePath` / `terminalPathLine`
* (constants.js) on the shapes real agent output has: Claude Code echoes an
* attached image as `› [image] ~/…/garage.png`, scripts print
* `builds/captures/…png`, and Claude Code wraps tool output a few columns
* short of the edge, so a long path arrives as `…/captures/2` + `026-10-10/x.png`.
* - The terminal link provider turns those into ONE link and opens the resolved
* path (relative against the session's working directory, `~/` as is).
* - The server expands `~/` (local: this host's home; remote: the probe on the
* remote host), so the attachment route can serve it.
*
* Real code under test: constants.js + terminal-ui.js in a `vm` context, and
* the shipped attachment registry / probe script.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { registerExternalAttachment } from '../src/attachment-registry.js';
import { buildRemoteProbeCommand, parseRemoteProbeOutput } from '../src/remote-files.js';
type Line = {
startRow: number;
endRow: number;
text: string;
offsetToCell: (o: number) => { row: number; col: number };
};
type Helpers = {
findFilePathLinks: (text: string) => Array<{ path: string; index: number }>;
resolveLinkedFilePath: (path: string, workingDir?: string) => string;
terminalPathLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => Line | null;
};
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
function loadHelpers(): Helpers {
const context = vm.createContext({});
vm.runInContext(
`${read('constants.js')}\nglobalThis.__h = { findFilePathLinks, resolveLinkedFilePath, terminalPathLine };`,
context,
{ filename: 'constants.js' }
);
return (context as { __h: Helpers }).__h;
}
const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpers();
const paths = (text: string) => findFilePathLinks(text).map((f) => f.path);
/** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */
function buffer(lines: string[], cols = 80, wrappedRows = new Set<number>()) {
return {
length: lines.length,
getLine: (row: number) =>
row >= 0 && row < lines.length
? {
isWrapped: wrappedRows.has(row),
translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)),
}
: undefined,
};
}
// Verbatim from a live pane (2026-10-10): the tool output wrapped at ~64 columns.
const BROKEN = [
' ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2',
' 026-10-10/ghost1b_gameplay.png err=0 kamera=(1108,13,811',
' ) blick=',
];
const BROKEN_PATH = '/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/ghost1b_gameplay.png';
describe('findFilePathLinks', () => {
it('links home-relative and relative paths', () => {
expect(paths(' › [image] ~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png (1.1MB)')).toEqual([
'~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png',
]);
expect(paths('2 Bericht: refs/shaderpatch_pixel_1010/BERICHT.md. Alle 12')).toEqual([
'refs/shaderpatch_pixel_1010/BERICHT.md',
]);
expect(paths('./a.md ../b.json look_montage.png')).toEqual(['./a.md', '../b.json', 'look_montage.png']);
});
it('keeps brackets, `=` and trailing punctuation out of a relative path', () => {
expect(paths('1. Garage-BG (garage_plate.png) — Bühne')).toEqual(['garage_plate.png']);
expect(paths('OUT=builds/captures/x.png, "shots/b.webp"')).toEqual(['builds/captures/x.png', 'shots/b.webp']);
expect(paths('see src/a.ts:120.')).toEqual(['src/a.ts']);
});
it("keeps a shell prompt's trailing `$` or `#` out of the folder it links", () => {
expect(paths('arkon@tnode:~/default/claudeman$ ls')).toEqual(['~/default/claudeman']);
expect(paths('root@c:/workspace/app# ls')).toEqual(['/workspace/app']);
});
it('still finds rooted absolute paths, and never a relative path inside one', () => {
expect(paths('sign-off from /mnt/raid1/x/refs/look_montage.png.')).toEqual(['/mnt/raid1/x/refs/look_montage.png']);
// Unknown root: not linked at all (no `nix/store/…` cut out of it).
expect(paths('/nix/store/abc-x/share/icon.png')).toEqual([]);
// `/etc` stays out on purpose (the server refuses that tree).
expect(paths('/etc/app/config.json')).toEqual([]);
});
it('never cuts a path out of a web URL, but keeps file:// paths', () => {
expect(paths('https://x.io/a/home/b.png?img=c.png')).toEqual([]);
expect(paths('file:///home/joe/x.png')).toEqual(['/home/joe/x.png']);
});
it('reports where each path starts', () => {
const text = 'a ~/x.png b /tmp/y.pdf c z.md';
for (const { path, index } of findFilePathLinks(text)) expect(text.slice(index, index + path.length)).toBe(path);
});
});
describe('resolveLinkedFilePath', () => {
it('resolves relative paths against the working directory, collapsing . and ..', () => {
expect(resolveLinkedFilePath('builds/x.png', '/mnt/ws')).toBe('/mnt/ws/builds/x.png');
expect(resolveLinkedFilePath('./a/./b.md', '/mnt/ws/')).toBe('/mnt/ws/a/b.md');
expect(resolveLinkedFilePath('../other/x.png', '/mnt/ws')).toBe('/mnt/other/x.png');
});
it('leaves absolute and ~/ paths alone, and relative ones without a working directory', () => {
expect(resolveLinkedFilePath('/tmp/a.png', '/mnt/ws')).toBe('/tmp/a.png');
expect(resolveLinkedFilePath('~/a.png', '/mnt/ws')).toBe('~/a.png');
expect(resolveLinkedFilePath('a.png', undefined)).toBe('a.png');
});
});
describe('terminalPathLine', () => {
it('glues a path broken short of the edge back together, from either row', () => {
for (const row of [0, 1]) {
const line = terminalPathLine(buffer(BROKEN), row, 80)!;
expect([line.startRow, line.endRow]).toEqual([0, 1]);
const [found] = findFilePathLinks(line.text);
expect(found.path).toBe(BROKEN_PATH);
// The cells are exact on both rows: after ` ⎿ BILD ` and after the 5-column indent.
expect(line.offsetToCell(found.index)).toEqual({ row: 0, col: 10 });
expect(line.offsetToCell(found.index + found.path.length - 1)).toEqual({ row: 1, col: 34 });
}
expect(terminalPathLine(buffer(BROKEN), 2, 80)).toMatchObject({ startRow: 2, endRow: 2 });
});
it('follows a path over three rows', () => {
const rows = ['x /tmp/claude-1001/-home-joe-cases', ' -neon/de4576ce-aaaaaaaaaaaaa', ' -56b4/shot.png'];
const line = terminalPathLine(buffer(rows), 1, 80)!;
expect(paths(line.text)).toEqual(['/tmp/claude-1001/-home-joe-cases-neon/de4576ce-aaaaaaaaaaaaa-56b4/shot.png']);
});
it('does not glue lines that are not a broken path', () => {
// A shorter line ending on a directory, then a longer one: not a wrap.
expect(terminalPathLine(buffer(['cd /mnt/raid1/foo', ' file.png erstellt']), 0, 80)).toMatchObject({ endRow: 0 });
// A chain that never reaches a file is dropped.
const listing = terminalPathLine(buffer(['cd ~/repos/neon', 'ls', 'a.png']), 2, 80)!;
expect(paths(listing.text)).toEqual(['a.png']);
// A path that already ended, and a next line opening a new rooted path.
expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder)', ' file.conf)']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder', ' /mnt/other/file.conf)']), 0, 80)).toMatchObject({
endRow: 0,
});
});
// `git ls-files`, `find` and `ls -R` print one path per row. An extensionless
// entry (a directory, `Dockerfile`, `.gitignore`) followed by an entry that is
// equal or shorter looked exactly like a path broken short of the edge, and the
// two were glued: `src/components` + `src/index.ts` linked `src/componentssrc/index.ts`
// and the real `src/index.ts` link was gone (Ark0N, PR #604, round 2).
describe('a listing is never glued into one path', () => {
const LISTING = [
'src/components',
'src/index.ts',
'src/lib',
'./index.ts',
'../docs/README.md',
'packages/gesture-control/.gitignore',
'packages/gesture-control/README.md',
'packages/gesture-control/package.json',
'data/runs/a.jsonl',
'data/runs/b.jsonl',
'data/runs/c.jsonl',
'data/runs/d.json',
];
it('keeps every row its own line', () => {
const buf = buffer(LISTING, 120);
LISTING.forEach((entry, row) => {
const line = terminalPathLine(buf, row, 120)!;
expect([line.startRow, line.endRow], entry).toEqual([row, row]);
expect(line.text, entry).toBe(entry);
});
});
it('links the entry that used to be swallowed', () => {
const buf = buffer(LISTING, 120);
expect(paths(terminalPathLine(buf, 1, 120)!.text)).toEqual(['src/index.ts']);
});
it('still glues a path whose next row opens on a different directory (a real wrap)', () => {
// The control: `…/captures/2` + `026-10-10/…` has a `/` in its head too.
const line = terminalPathLine(buffer(BROKEN), 0, 80)!;
expect([line.startRow, line.endRow]).toEqual([0, 1]);
expect(paths(line.text)).toEqual([BROKEN_PATH]);
});
});
});
type Link = {
text: string;
range: { start: { x: number; y: number }; end: { x: number; y: number } };
activate: (ev: unknown, text: string) => void;
};
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
function loadApp() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' }));
app.showToast = vi.fn();
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
return app;
}
function linksOn(lines: string[], row: number, cols = 80, wrappedRows = new Set<number>()) {
const app = loadApp();
app.terminal = { cols, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines, cols, wrappedRows) } };
const provider = app.registerFilePathLinkProvider() as Provider;
let out: Link[] = [];
provider.provideLinks(row, (links) => {
out = links || [];
});
return { app, links: out };
}
describe('terminal link provider', () => {
it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])(
'keeps the workspace opener for a hard-wrapped .%s rejected by the attachment registry',
async (extension) => {
for (const row of [1, 2]) {
const path = `/home/me/proj/out/system_architecture.${extension}`;
const { app, links } = linksOn(
['Saved the diagram (/home/me/proj/out/system_archi', ` tecture.${extension}).`],
row,
120
);
app.sessions = new Map([['s1', { workingDir: '/home/me/proj' }]]);
app._registerExternalPreview.mockResolvedValue({ status: 400, error: 'Unsupported attachment type' });
await links[0].activate({}, links[0].text);
const opener = extension === 'zip' ? app.openLogViewerWindow : app.openFilePreview;
expect(opener).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
expect(app.showToast).not.toHaveBeenCalled();
}
}
);
it.each([
[
['Saved the screenshot (/home/me/proj/out/ghost1b_game', ' play.png).'],
'/home/me/proj/out/ghost1b_gameplay.png',
],
[
[' ⎿ Wrote (/mnt/build/neon/builds/captures/ghost1b_gamepl', ' ay.png) ok'],
'/mnt/build/neon/builds/captures/ghost1b_gameplay.png',
],
[
['Saved the screenshot (/home/me/proj/out/ghost1b_', ' very_long_game', ' play.png).'],
'/home/me/proj/out/ghost1b_very_long_gameplay.png',
],
])('preserves a parenthesised filename without spaces across hard wraps: %s', async (rows, path) => {
for (let row = 1; row <= rows.length; row++) {
const { app, links } = linksOn(rows, row, 120);
expect(links.map((link) => link.text)).toEqual([path]);
await links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
}
});
it('uses the spaced filename only when the unspaced hard-wrap target is missing', async () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const rows = [
'Found it: I prepared a corrected configuration (/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo',
' eReader.conf).',
];
for (const row of [1, 2]) {
const { app, links } = linksOn(rows, row, 120);
const unspaced = path.replace('Kobo eReader', 'KoboeReader');
expect(links.map((link) => link.text)).toEqual([unspaced]);
expect(links[0].range.end).toEqual({ x: ' eReader.conf'.length + 1, y: 2 });
app._registerExternalPreview.mockImplementation(async (candidate: string) =>
candidate === path ? { attachmentId: 'att-fixture' } : { status: 404, error: 'File not found' }
);
await links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([
[unspaced, 's1'],
[path, 's1'],
]);
}
});
it.each([403, 500, 503])('does not try another path after a guarded lookup returns HTTP %s', async (status) => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview.mockResolvedValue({ status, error: 'Lookup refused' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.showToast).not.toHaveBeenCalled();
});
it('keeps the original opener when the lookup cannot reach the server', async () => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview.mockResolvedValue({ error: 'Connection lost' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('never looks up a spaced name for a hard-wrapped Bash( row, which holds a command', async () => {
const { app, links } = linksOn(['Bash(/mnt/data/Kobo', ' eReader.conf)'], 1, 120);
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).not.toHaveBeenCalled();
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('uses the original opener when both hard-wrap filenames are missing', async () => {
for (const row of [1, 2]) {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], row, 120);
app._registerExternalPreview.mockResolvedValue({ status: 404, error: 'Attachment file not found' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/mnt/data/KoboeReader.conf', 's1'],
['/mnt/data/Kobo eReader.conf', 's1'],
]);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.showToast).not.toHaveBeenCalled();
}
});
it.each(['Path: ', '- ', '- Path: ', ' Path: '])(
'resolves the filename without its parenthesised prefix %j',
async (prefix) => {
for (const row of [1, 2]) {
const { app, links } = linksOn([`Saved the notes (${prefix}/home/me/proj/out/My`, ' Notes.md).'], row, 120);
app._registerExternalPreview
.mockResolvedValueOnce({ status: 404, error: 'Attachment file not found' })
.mockResolvedValueOnce({ attachmentId: 'att-spaced' });
expect(links.map((link) => link.text)).toEqual(['/home/me/proj/out/MyNotes.md']);
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/home/me/proj/out/MyNotes.md', 's1'],
['/home/me/proj/out/My Notes.md', 's1'],
]);
expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/proj/out/My Notes.md', 's1');
}
}
);
it.each([400, 403, 500, 503])('does not open an unregistered spaced alternative after HTTP %s', async (status) => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview
.mockResolvedValueOnce({ status: 404, error: 'File not found' })
.mockResolvedValueOnce({ status, error: 'Alternative unavailable' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/mnt/data/KoboeReader.conf', 's1'],
['/mnt/data/Kobo eReader.conf', 's1'],
]);
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Alternative unavailable', 'error');
});
it('keeps the session selected at click time while a path lookup is pending', async () => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
const pending = links[0].activate({}, links[0].text);
app.activeSessionId = 'another-session';
await pending;
expect(app._registerExternalPreview).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('opens the complete parenthesised configuration path from either wrapped row', () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const text = `Found it: I prepared a corrected configuration (${path}).`;
const split = text.indexOf(' eReader') + 1;
const rows = [text.slice(0, split), text.slice(split)];
for (const row of [1, 2]) {
const { app, links } = linksOn(rows, row, split, new Set([1]));
expect(links.map((link) => link.text)).toEqual([path]);
expect(links[0].range.start).toEqual({ x: text.indexOf(path) + 1, y: 1 });
expect(links[0].range.end).toEqual({ x: 'eReader.conf'.length + 1, y: 2 });
links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
}
});
it('links a broken path once, across both rows, and opens the whole path', () => {
const { app, links } = linksOn(BROKEN, 2);
expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]);
expect(links[0].range.start).toEqual({ x: 11, y: 1 });
expect(links[0].range.end.y).toBe(2);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(BROKEN_PATH, 's1');
});
it('opens a relative path resolved against the working directory', () => {
const { app, links } = linksOn([' ⎿ wrote builds/captures/2026-10-10/a.png'], 1);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(
'/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/a.png',
's1'
);
});
it('opens a ~/ path as is, in the preview (the server expands it), even for text', () => {
const { app, links } = linksOn([' › [image] ~/repos/x/garage.png', 'notes ~/logs/run.log'], 2);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith('~/logs/run.log', 's1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('opens a relative text file in the log viewer by its resolved path', () => {
const { app, links } = linksOn(['tail logs/run.log'], 1);
links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/build/neon_getaway-mgr-hebel/logs/run.log', 's1');
});
it('routes .md/.markdown and .html/.htm in every link form to the preview, other text to the log viewer', () => {
// The rendered markdown viewer and the HTML page view (rendersAsPage) apply to
// `~/`, relative and hard-wrapped links exactly as to rooted ones.
const WS = '/mnt/build/neon_getaway-mgr-hebel';
const cases: Array<[string[], number, string, 'preview' | 'log']> = [
[['wrote ~/notes/plan.md'], 1, '~/notes/plan.md', 'preview'],
[['wrote ~/site/index.html'], 1, '~/site/index.html', 'preview'],
[['wrote docs/README.markdown'], 1, `${WS}/docs/README.markdown`, 'preview'],
[['wrote report/index.htm'], 1, `${WS}/report/index.htm`, 'preview'],
[['wrote ./notes/plan.md'], 1, `${WS}/notes/plan.md`, 'preview'],
[['wrote out/data.json'], 1, `${WS}/out/data.json`, 'log'],
[
[' ⎿ WROTE /mnt/build/neon_getaway-mgr-hebel/builds/reports/2', ' 026-10-10/summary.md done', ''],
2,
`${WS}/builds/reports/2026-10-10/summary.md`,
'preview',
],
[
[' ⎿ WROTE /mnt/build/neon_getaway-mgr-hebel/builds/reports/2', ' 026-10-10/page.html done', ''],
2,
`${WS}/builds/reports/2026-10-10/page.html`,
'preview',
],
];
for (const [lines, row, expected, where] of cases) {
const { app, links } = linksOn(lines, row);
expect(links.length, expected).toBe(1);
links[0].activate({}, links[0].text);
if (where === 'preview') {
expect(app.openFilePreview, expected).toHaveBeenCalledWith(expected, 's1');
expect(app.openLogViewerWindow, expected).not.toHaveBeenCalled();
} else {
expect(app.openLogViewerWindow, expected).toHaveBeenCalledWith(expected, 's1');
expect(app.openFilePreview, expected).not.toHaveBeenCalled();
}
}
});
it('never adds a path link overlapping a URL link', () => {
const { links } = linksOn(['see https://x.io/view?img=shots/a.png now'], 1);
expect(links.map((l) => l.text)).toEqual(['https://x.io/view?img=shots/a.png']);
});
});
describe('server: ~/ expands on the session host', () => {
const root = mkdtempSync(join(tmpdir(), 'codeman-tilde-'));
afterAll(() => rmSync(root, { recursive: true, force: true }));
it('a local session registers ~/… against this host home', async () => {
// test/setup.ts points HOME at a temp dir, so this never touches the real home.
const dir = join(homedir(), 'tilde-link-test');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'shot.png'), 'PNG');
try {
const event = await registerExternalAttachment('tilde-session', '~/tilde-link-test/shot.png', {});
expect(event.attachmentId).toMatch(/^att_/);
expect(event.size).toBe(3);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('a relative path is still refused', async () => {
await expect(registerExternalAttachment('tilde-session', 'shots/a.png', {})).rejects.toThrow(/absolute/);
});
it('the remote probe expands ~/ to the remote $HOME, and reports the absolute path', () => {
writeFileSync(join(root, 'remote.png'), 'PNGPNG');
const requested = ['~/remote.png', '~/missing.png'];
const stdout = execFileSync('sh', ['-c', buildRemoteProbeCommand(requested)], {
env: { ...process.env, HOME: root },
}).toString();
const [found, missing] = parseRemoteProbeOutput(stdout, requested);
expect(found).toMatchObject({ kind: 'file', size: 6 });
expect(found?.realPath.endsWith('/remote.png')).toBe(true);
expect(found?.realPath.startsWith('/')).toBe(true);
expect(missing).toBeNull();
});
});
+2
View File
@@ -40,6 +40,8 @@ function loadApp() {
escapeHtml: (s: string) => String(s),
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
CodemanBase: { base: '', url: (p: string) => p },
// Also from constants.js: no file in this suite renders as an HTML page.
rendersAsPage: () => false,
document: { getElementById: () => null, addEventListener: vi.fn() },
window: windowStub,
setTimeout,
+217
View File
@@ -0,0 +1,217 @@
/**
* @fileoverview File Viewer: an `.html` file renders as a sandboxed page.
*
* The server half (`/html-view/<cap>/...`, html-view-routes.ts) is pinned by
* test/routes/html-view-routes.test.ts. This file pins the frontend half:
*
* 1. `rendersAsPage()` (constants.js) and the server's HTML_VIEW_EXTENSIONS
* name the same extensions, so the terminal, the preview and the route
* agree on what a page is.
* 2. A successful mint renders an iframe whose `sandbox` carries scripts but
* never `allow-same-origin` (with it, the page could drive Codeman), with
* the Page pill shown and pressed.
* 3. A refused mint (remote case, broad folder, hidden directory) falls
* through to the source view with its Edit button, and hides the pill,
* which would only retry a page the server just refused.
* 4. The pill flips between page and source and persists
* `codeman:filePreviewHtmlRendered` per device; with it off the preview
* opens as source without asking the server for a page at all.
*
* Loaded via `vm` with a jsdom document injected, like
* file-preview-markdown.test.ts: constants.js + panels-ui.js only.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import { HTML_VIEW_EXTENSIONS } from '../src/web/routes/html-view-routes.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const constantsJs = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
// A real origin: jsdom's localStorage getter throws on an opaque one.
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
const { document } = dom.window;
const PAGE_PATH = '/home/u/cases/demo/report/index.html';
const PAGE_URL = '/html-view/AAAAAAAAAAAAAAAAAAAAAAAA/index.html';
const HTML_SOURCE = '<!doctype html>\n<h1>Report</h1>\n';
function jsonResponse(body: unknown, status = 200) {
return { ok: status < 400, status, json: async () => body, text: async () => JSON.stringify(body) };
}
/**
* The two routes the preview talks to for an HTML file: the mint (answered or
* refused per `mint`) and file-content, which serves the source view.
*/
function makeFetch(mint: 'ok' | 'refuse') {
return vi.fn(async (url: string, init?: { method?: string; body?: string }) => {
if (url.endsWith('/html-view')) {
return mint === 'ok'
? jsonResponse({ success: true, data: { url: PAGE_URL } })
: jsonResponse({ success: false, error: 'This folder is too broad to render a page from' }, 403);
}
if (url.includes('/file-content')) {
return jsonResponse({
success: true,
data: { content: HTML_SOURCE, totalLines: 3, size: HTML_SOURCE.length, truncated: false, editable: true },
});
}
throw new Error(`unexpected fetch ${init?.method || 'GET'} ${url}`);
});
}
function loadApp(mint: 'ok' | 'refuse', prefs: Record<string, string> = {}) {
const store = new Map(Object.entries(prefs));
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const fetchMock = makeFetch(mint);
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
document,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
MobileDetection: {},
setTimeout,
clearTimeout,
confirm: () => true,
fetch: fetchMock,
});
vm.runInContext(`${constantsJs}\n${panelsJs}`, context, { filename: 'panels-ui.js' });
// The overlay's elements, as index.html ships them.
document.body.innerHTML = `
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
<button id="filePreviewMdBtn" hidden></button>
<button id="filePreviewPageBtn" hidden></button>
<button id="filePreviewLinesBtn" hidden></button>
<button id="filePreviewWrapBtn" hidden></button>
<button id="filePreviewEditBtn" hidden></button>
<button id="filePreviewDetachBtn" hidden></button>
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app._resetFilePreviewEdit = () => {
const edit = document.getElementById('filePreviewEditBtn');
if (edit) edit.hidden = true;
};
app._isExternalPreviewPath = () => false;
app.formatFileSize = (n: number) => `${n} B`;
app.filePreviewContent = '';
const byId = (id: string) => document.getElementById(id) as HTMLElement;
return {
app,
fetchMock,
store,
body: byId('filePreviewBody'),
footer: byId('filePreviewFooter'),
page: byId('filePreviewPageBtn'),
edit: byId('filePreviewEditBtn'),
};
}
/** The mint requests the stub saw, as parsed bodies. */
function mintCalls(fetchMock: ReturnType<typeof makeFetch>) {
return fetchMock.mock.calls
.filter(([url]) => url.endsWith('/html-view'))
.map(([url, init]) => ({ url, method: init?.method, body: JSON.parse(init?.body || '{}') }));
}
describe('rendersAsPage', () => {
it('names exactly the extensions the html-view route renders', () => {
const context = vm.createContext({});
vm.runInContext(`${constantsJs}\nglobalThis.__page = { rendersAsPage, HTML_PAGE_EXTENSIONS };`, context, {
filename: 'constants.js',
});
const { rendersAsPage, HTML_PAGE_EXTENSIONS } = (
context as { __page: { rendersAsPage: (p: string) => boolean; HTML_PAGE_EXTENSIONS: Set<string> } }
).__page;
expect([...HTML_PAGE_EXTENSIONS].sort()).toEqual([...HTML_VIEW_EXTENSIONS].sort());
expect(rendersAsPage('/tmp/out/report.html')).toBe(true);
expect(rendersAsPage('docs/INDEX.HTM')).toBe(true);
expect(rendersAsPage('notes.md')).toBe(false);
expect(rendersAsPage('page.html.txt')).toBe(false);
expect(rendersAsPage(undefined as unknown as string)).toBe(false);
});
});
describe('file viewer HTML page', () => {
it('renders a minted page in a sandboxed iframe that never gets allow-same-origin', async () => {
const { app, fetchMock, body, footer, page, edit } = loadApp('ok');
await app.openFilePreview(PAGE_PATH, 's1');
expect(mintCalls(fetchMock)).toEqual([
{ url: '/api/sessions/s1/html-view', method: 'POST', body: { path: PAGE_PATH } },
]);
const frame = body.querySelector('iframe')!;
expect(frame.getAttribute('src')).toBe(PAGE_URL);
const sandbox = (frame.getAttribute('sandbox') || '').split(/\s+/);
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
expect(footer.textContent).toBe('HTML');
expect(app.filePreviewDetachUrl).toBe(PAGE_URL);
expect(page.hidden).toBe(false);
expect(page.getAttribute('aria-pressed')).toBe('true');
// A page has no text for Edit to act on.
expect(edit.hidden).toBe(true);
});
it('falls back to the source view with Edit and no Page pill when the mint is refused', async () => {
const { app, fetchMock, body, page, edit } = loadApp('refuse');
await app.openFilePreview(PAGE_PATH, 's1');
expect(mintCalls(fetchMock)).toHaveLength(1);
expect(body.querySelector('iframe')).toBeNull();
const pre = body.querySelector('pre.file-preview-text')!;
expect(pre.textContent).toBe(HTML_SOURCE);
expect(page.hidden).toBe(true);
expect(edit.hidden).toBe(false);
});
it('flips between page and source with the pill and persists the choice per device', async () => {
const { app, fetchMock, body, page, edit, store } = loadApp('ok');
await app.openFilePreview(PAGE_PATH, 's1');
expect(body.querySelector('iframe')).not.toBeNull();
await app.toggleFilePreviewPage();
expect(store.get('codeman:filePreviewHtmlRendered')).toBe('0');
expect(body.querySelector('iframe')).toBeNull();
expect(body.querySelector('pre.file-preview-text')!.textContent).toBe(HTML_SOURCE);
expect(page.hidden).toBe(false);
expect(page.getAttribute('aria-pressed')).toBe('false');
expect(edit.hidden).toBe(false);
// Source never asks the server for a page.
expect(mintCalls(fetchMock)).toHaveLength(1);
await app.toggleFilePreviewPage();
expect(store.get('codeman:filePreviewHtmlRendered')).toBe('1');
expect(body.querySelector('iframe')!.getAttribute('src')).toBe(PAGE_URL);
expect(page.getAttribute('aria-pressed')).toBe('true');
expect(mintCalls(fetchMock)).toHaveLength(2);
});
it('opens as source, pill offered, when the device pref says so', async () => {
const { app, fetchMock, body, page } = loadApp('ok', { 'codeman:filePreviewHtmlRendered': '0' });
await app.openFilePreview(PAGE_PATH, 's1');
expect(mintCalls(fetchMock)).toHaveLength(0);
expect(body.querySelector('pre.file-preview-text')!.textContent).toBe(HTML_SOURCE);
expect(page.hidden).toBe(false);
expect(page.getAttribute('aria-pressed')).toBe('false');
});
});
+9 -4
View File
@@ -19,8 +19,10 @@
* 4. Lines/Wrap flip classes on the <pre> and persist, and the text the <pre>
* holds is byte-identical to the file; every toggle is hidden for an image
* and while editing.
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico and still has no `md`
* (in-workspace text keeps the tail viewer, see architecture-invariants).
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico, and `md`/`markdown` (owner
* decision 2026-10-13: a markdown path clicked in the terminal opens this
* rendered view, not the streaming log viewer), while other in-workspace
* text keeps the tail viewer (see architecture-invariants).
* 6. A preview opened by attachment id under a bare file name has no
* directory to resolve against, so its relative images degrade to alt text
* and its relative links to plain text instead of landing on the workspace
@@ -464,10 +466,13 @@ describe('file viewer markdown line breaks', () => {
});
describe('FILE_PREVIEW_EXTENSIONS', () => {
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
it('routes avif, ico and markdown paths to the viewer and leaves other text with the tail viewer', () => {
const { exts } = loadApp();
expect(exts.has('avif')).toBe(true);
expect(exts.has('ico')).toBe(true);
expect(exts.has('md')).toBe(false);
// Markdown is a document to read rendered, not a log to follow.
expect(exts.has('md')).toBe(true);
expect(exts.has('markdown')).toBe(true);
for (const text of ['log', 'txt', 'json', 'ts', 'yaml', 'csv']) expect(exts.has(text), text).toBe(false);
});
});
+2
View File
@@ -66,6 +66,8 @@ function loadApp(media: FakeMedia[]) {
escapeHtml: (s: string) => String(s),
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
CodemanBase: { base: '', url: (p: string) => p },
// Also from constants.js: no file in this suite renders as an HTML page.
rendersAsPage: () => false,
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
+409
View File
@@ -0,0 +1,409 @@
/**
* @fileoverview File Viewer window: back/forward between previews, expand, resize.
*
* Links between documents open the next file in the same overlay with no way back.
* Pinned here:
* 1. jumping between files fills a back/forward list and the buttons reveal,
* 2. back/forward reopen the right file WITHOUT recording a new entry,
* 3. opening a file mid-list drops the forward entries,
* 4. reopening the file already showing (MD/Page toggles) adds nothing,
* 5. closing the preview clears the list and hides the buttons,
* 6. unsaved edits ask before leaving,
* 7. Expand toggles the window class and the pref, and is re-applied on open,
* 8. the markup carries the buttons and the resize grip,
* 9. stepping onto a folder-grid page moves the grid's own index with it,
* 10. Alt+←/→ steps, ignores other chords and text fields, and is swallowed at the ends,
* 11. the corner grip doubles the pointer delta and clamps the size,
* 12. phones ignore Expand and the grip (mobile.css),
* 13. the rendered document carries no width cap, so it fills the window the
* grip and Expand give it (styles.css).
*
* Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
function btn() {
const attrs: Record<string, string> = {};
return {
hidden: true,
disabled: true,
title: '',
setAttribute: (k: string, v: string) => (attrs[k] = v),
attrs,
};
}
function loadApp(confirmResult = true) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const store: Record<string, string> = {};
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => (store[k] = v),
removeItem: () => {},
},
escapeHtml: (s: string) => String(s),
CodemanBase: { base: '', url: (p: string) => p },
rendersAsPage: () => false,
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn(), open: vi.fn(), innerWidth: 1000, innerHeight: 800 },
setTimeout,
clearTimeout,
confirm: vi.fn(() => confirmResult),
fetch: () => {
throw new Error('fetch not stubbed');
},
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const winClasses = new Set<string>();
const windowEl = {
classList: {
toggle: (c: string, on: boolean) => (on ? winClasses.add(c) : winClasses.delete(c)),
contains: (c: string) => winClasses.has(c),
},
};
const overlayClasses = new Set<string>(['visible']);
const overlay = {
classList: {
add: (c: string) => overlayClasses.add(c),
remove: (c: string) => overlayClasses.delete(c),
contains: (c: string) => overlayClasses.has(c),
},
querySelector: () => windowEl,
};
const back = btn();
const fwd = btn();
const expand = btn();
const elements: Record<string, unknown> = {
filePreviewBody: { innerHTML: '', querySelectorAll: () => [], querySelector: () => null },
filePreviewOverlay: overlay,
filePreviewTitle: { textContent: '' },
filePreviewFooter: { textContent: '' },
filePreviewDetachBtn: { hidden: true },
filePreviewBackBtn: back,
filePreviewForwardBtn: fwd,
filePreviewExpandBtn: expand,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app._resetFilePreviewEdit = () => {};
app._isExternalPreviewPath = () => false;
app.showToast = vi.fn();
app.filePreviewContent = '';
const confirmFn = (context as { confirm: ReturnType<typeof vi.fn> }).confirm;
const keydown = () => {
const calls = (context as { document: { addEventListener: ReturnType<typeof vi.fn> } }).document.addEventListener
.mock.calls;
return calls.find((c: unknown[]) => c[0] === 'keydown')?.[1] as (ev: unknown) => void;
};
return { app, overlay, back, fwd, expand, winClasses, store, confirmFn, elements, overlayClasses, keydown };
}
describe('file viewer back/forward', () => {
it('stays hidden for one file and reveals once a second one is opened', async () => {
const { app, back, fwd } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
expect(back.hidden).toBe(true);
await app.openFilePreview('/ws/b.pdf', 's1');
expect(back.hidden).toBe(false);
expect(back.disabled).toBe(false);
expect(fwd.hidden).toBe(false);
expect(fwd.disabled).toBe(true);
});
it('steps back and forward without recording the step as a new entry', async () => {
const { app, back, fwd } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
await app.openFilePreview('/ws/c.pdf', 's1');
await app.stepFilePreviewHistory(-1);
expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([
'/ws/a.pdf',
'/ws/b.pdf',
'/ws/c.pdf',
]);
expect(app.filePreviewHistory.index).toBe(1);
expect(app.filePreviewDetachUrl).toContain(encodeURIComponent('/ws/b.pdf'));
expect(fwd.disabled).toBe(false);
await app.stepFilePreviewHistory(-1);
expect(back.disabled).toBe(true);
await app.stepFilePreviewHistory(-1); // at the start: a no-op
expect(app.filePreviewHistory.index).toBe(0);
await app.stepFilePreviewHistory(1);
expect(app.filePreviewHistory.index).toBe(1);
});
it('drops the forward entries when a new file is opened mid-list', async () => {
const { app } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
await app.stepFilePreviewHistory(-1);
await app.openFilePreview('/ws/c.pdf', 's1');
expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([
'/ws/a.pdf',
'/ws/c.pdf',
]);
expect(app.filePreviewHistory.index).toBe(1);
});
it('does not add an entry when the file already showing is reopened', async () => {
const { app } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/a.pdf', 's1');
expect(app.filePreviewHistory.entries).toHaveLength(1);
});
it('clears the list and hides the buttons on close', async () => {
const { app, back, fwd } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
app.closeFilePreview();
expect(app.filePreviewHistory).toBeNull();
expect(back.hidden).toBe(true);
expect(fwd.hidden).toBe(true);
});
it('asks before leaving unsaved edits and stays put when declined', async () => {
const { app, confirmFn } = loadApp(false);
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
app.filePreviewEdit = { dirty: true };
await app.stepFilePreviewHistory(-1);
expect(confirmFn).toHaveBeenCalled();
expect(app.filePreviewHistory.index).toBe(1);
});
});
describe('file viewer expand', () => {
it('toggles the window class, the pref and the button state', () => {
const { app, winClasses, store, expand } = loadApp();
// As index.html ships it.
expand.title = 'Expand';
expand.attrs['aria-label'] = 'Expand';
app.toggleFilePreviewExpanded();
expect(winClasses.has('expanded')).toBe(true);
expect(store['codeman:filePreviewExpanded']).toBe('1');
expect(expand.attrs['aria-pressed']).toBe('true');
// aria-pressed carries the state; the label stays fixed, like the MD/Lines/Wrap toggles.
expect(expand.title).toBe('Expand');
expect(expand.attrs['aria-label']).toBe('Expand');
app.toggleFilePreviewExpanded();
expect(winClasses.has('expanded')).toBe(false);
expect(expand.attrs['aria-pressed']).toBe('false');
expect(expand.title).toBe('Expand');
});
it('re-applies a remembered expanded state when a preview opens', async () => {
const { app, winClasses, store } = loadApp();
store['codeman:filePreviewExpanded'] = '1';
await app.openFilePreview('/ws/a.pdf', 's1');
expect(winClasses.has('expanded')).toBe(true);
});
});
describe('file viewer window markup', () => {
it('carries the back, forward and expand buttons and the resize grip', () => {
for (const id of ['filePreviewBackBtn', 'filePreviewForwardBtn', 'filePreviewExpandBtn', 'filePreviewResize']) {
expect(indexHtml).toContain(`id="${id}"`);
}
expect(indexHtml).toContain('app.stepFilePreviewHistory(-1)');
expect(indexHtml).toContain('app.stepFilePreviewHistory(1)');
});
});
describe('file viewer folder paging in the history', () => {
it('moves the grid index with a back step, so the next page continues from there', async () => {
const { app } = loadApp();
const names = ['a', 'b', 'c', 'd'].map((n) => ({ name: `${n}.pdf`, size: 1 }));
app.folderGrid = {
folderId: 'f1',
path: '/ws',
sessionId: 's1',
files: names,
folders: [],
index: null,
lastIndex: null,
};
app._openFolderGridFile(0);
app._openFolderGridFile(1);
app._openFolderGridFile(2);
await app.stepFilePreviewHistory(-1);
expect(app.folderGrid.index).toBe(1);
expect(app.folderGrid.lastIndex).toBe(1);
app.stepFolderPreview(1);
const paths = app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath);
expect(paths).toEqual(['/ws/a.pdf', '/ws/b.pdf', '/ws/c.pdf']);
expect(app.folderGrid.index).toBe(2);
});
it('falls back to a plain open when the entry is from another folder', async () => {
const { app } = loadApp();
app.folderGrid = {
folderId: 'f1',
path: '/ws',
sessionId: 's1',
files: [{ name: 'a.pdf', size: 1 }],
folders: [],
index: 0,
lastIndex: 0,
};
await app.openFilePreview('/other/x.pdf', 's1', null, { folderFile: { folderId: 'f2', name: 'x.pdf' } });
await app.openFilePreview('/ws/y.pdf', 's1');
await app.stepFilePreviewHistory(-1);
expect(app.filePreviewHistory.index).toBe(0);
expect(app.filePreviewHistory.entries).toHaveLength(2);
});
});
describe('file viewer Alt+arrow chord', () => {
const key = (over: Record<string, unknown> = {}) => ({
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
key: 'ArrowLeft',
target: { tagName: 'DIV' },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...over,
});
async function setup() {
const h = loadApp();
await h.app.openFilePreview('/ws/a.pdf', 's1');
await h.app.openFilePreview('/ws/b.pdf', 's1');
h.app._bindFilePreviewChrome();
const step = vi.spyOn(h.app, 'stepFilePreviewHistory');
return { ...h, step, fire: h.keydown() };
}
it('steps back and forward and swallows the chord', async () => {
const { fire, step } = await setup();
const ev = key();
fire(ev);
expect(step).toHaveBeenCalledWith(-1);
expect(ev.preventDefault).toHaveBeenCalled();
expect(ev.stopPropagation).toHaveBeenCalled();
});
it('swallows the chord at the ends of the list without stepping', async () => {
const { fire, step } = await setup();
const fwd = key({ key: 'ArrowRight' }); // already at the newest entry
fire(fwd);
expect(step).not.toHaveBeenCalled();
expect(fwd.preventDefault).toHaveBeenCalled();
expect(fwd.stopPropagation).toHaveBeenCalled();
});
it.each([
['a plain arrow', { altKey: false }],
['Shift+Alt', { shiftKey: true }],
['Ctrl+Alt', { ctrlKey: true }],
['another key', { key: 'ArrowUp' }],
['a textarea target', { target: { tagName: 'TEXTAREA', classList: { contains: () => false } } }],
])('leaves %s alone', async (_name, over) => {
const { fire, step } = await setup();
const ev = key(over);
fire(ev);
expect(step).not.toHaveBeenCalled();
expect(ev.preventDefault).not.toHaveBeenCalled();
});
it('leaves the chord alone while the overlay is hidden', async () => {
const { fire, step, overlayClasses } = await setup();
overlayClasses.delete('visible');
const ev = key();
fire(ev);
expect(step).not.toHaveBeenCalled();
expect(ev.preventDefault).not.toHaveBeenCalled();
});
});
describe('file viewer resize grip', () => {
function drag(to: [number, number], button = 0) {
const { app, elements } = loadApp();
const listeners: Record<string, (e: unknown) => void> = {};
const win = { offsetWidth: 400, offsetHeight: 300, style: {} as Record<string, string> };
elements.filePreviewResize = {
parentElement: win,
setPointerCapture: () => {},
addEventListener: (t: string, fn: (e: unknown) => void) => (listeners[t] = fn),
removeEventListener: () => {},
};
app._bindFilePreviewResize();
listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1, button });
if (!listeners.pointermove) return win.style; // a refused pointerdown binds no move handler
listeners.pointermove({ clientX: 100 + to[0], clientY: 100 + to[1] });
return win.style;
}
it('ignores a pointerdown that is not the primary button', () => {
expect(drag([100, 50], 2)).toEqual({});
});
it('doubles the pointer delta, because the window is centred', () => {
expect(drag([100, 50])).toMatchObject({ width: '600px', height: '400px' });
});
it('clamps to the minimum and to 96% x 94% of the viewport', () => {
expect(drag([-500, -500])).toMatchObject({ width: '320px', height: '240px' });
expect(drag([900, 900])).toMatchObject({ width: '960px', height: '752px' });
});
});
describe('file viewer rendered markdown width', () => {
it('carries no fixed reading column, so the document fills the window the grip and Expand give it', () => {
const mdRule = stylesCss.match(/\.file-preview-body > \.file-preview-md \{[^}]*\}/);
expect(mdRule, 'the rule the rendered document lives in').not.toBeNull();
// Any cap here reflows nothing once the window is wider than it, which is
// what the shipped 960px column did. The window width is the user's choice,
// so nothing in this rule may cap it.
expect(mdRule![0], 'no width cap on the document').not.toMatch(/max-width/);
// The shared .rv-text prose rule must not reintroduce one either.
const rvBase = stylesCss.match(/\.rv-text,\s*\.response-viewer-body > :not\(\.rv-message\) \{[^}]*\}/);
expect(rvBase, 'the shared prose rule').not.toBeNull();
expect(rvBase![0], 'no width cap on the shared prose rule').not.toMatch(/max-width/);
// ASCII-diagram blocks cap themselves at var(--rv-content-max, 720px), which only the
// Response Viewer sets; the document lifts it so a diagram follows the window too.
expect(mdRule![0], 'diagram blocks follow the window').toMatch(/--rv-content-max:\s*none;/);
});
});
describe('file viewer on phones', () => {
it('overrides Expand and any dragged size, and hides both controls, under 600px', () => {
const css = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
const phone = css.slice(css.indexOf('@media (max-width: 599px)'));
expect(phone).toMatch(
/\.file-preview-window,\s*\.file-preview-window\.expanded\s*\{[^}]*width: 100vw !important;[^}]*height: var\(--app-height, 100vh\) !important;/
);
expect(phone).toMatch(/#filePreviewExpandBtn,\s*\.file-preview-resize\s*\{\s*display: none;/);
});
});
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Working (5s • esc to interrupt)
└ Tip: Use /feedback to send logs to the maintainers when something looks off.
› Ask Codex to do anything
GPT-6-Luna medium · /tmp/codex-scratch/repo · Request approval to create file Plan mode
? for shortcuts
@@ -0,0 +1,23 @@
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to
ask me which animal to write in it, with 3 options. Ask
only that one question.
■ Conversation interrupted - use /feedback if something went
wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to
cancel tab to add notes | enter to submit answer | esc
to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo Plan mode
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
■ Conversation interrupted - use /feedback if something went wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to cancel tab to add notes | enter to submit answer | esc to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo · Ask for animal choice Plan mode (shift+tab to cycle)
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
GPT-6-Luna default · /tmp/codex-scratch/repo
+50
View File
@@ -0,0 +1,50 @@
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
Would you like to make the following edits?
Description: Apply proposed file edits
Destination: /tmp/codex-scratch/repo/calc.py
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+14
View File
@@ -0,0 +1,14 @@
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with
write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
[… 21 lines] ^a view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,50 @@
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
› Run this exact multi-line shell command with escalated permissions (ask me for approval): a bash heredoc that writes 12 lines 'line 1' through 'line 12' into lines.txt, using cat > lines.txt with a
quoted heredoc, each line written out literally.
• I’ll request approval to run the quoted heredoc that writes the 12 literal lines to lines.txt.
• Running cat > lines.txt <<'EOF' …
+ Show details
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
line 11
line 12
EOF
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+30
View File
@@ -0,0 +1,30 @@
› Run: touch second.txt (needs app…
access outside the read only
sandbox. I’ll request approval to
run that exact command.
• Running touch second.txt
Would you like to run the
following command?
Environment: local
Reason: May I run `touch
second.txt` outside the read
only sandbox to create the
requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for
commands that start with `touch
second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to
cancel
+30
View File
@@ -0,0 +1,30 @@
outside the sandbox)
• Creating second.txt requires write access
outside the read only sandbox. I’ll
request approval to run that exact
command.
• Running touch second.txt
Would you like to run the following
command?
Environment: local
Reason: May I run `touch second.txt`
outside the read only sandbox to create
the requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands
that start with `touch second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
• Running touch created.txt
Would you like to run the following command?
Environment: local
Reason: May I run `touch created.txt` outside the read only sandbox to create the requested file?
$ touch created.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `touch created.txt` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
⣀⣤⣤⣤⣀⣀
⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀
⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄
⣀⣾⣿⣿⠃ ⣀⣴⣾⣿⣿⡿⠟⠋ ⣀⡀ ⠈⠙⢿⣿⣿⣧
⣀⣶⣿⣿⣿⣿⡇ ⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇
⣰⣿⣿⡿⢿⣿⣿⡇ ⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷
⢰⣿⣿⡟⠁⢸⣿⣿⡇ ⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀
⣾⣿⣿⠃ ⢸⣿⣿⡇ ⢸⣿⣿⠋⠁ ⠈⠙⢻⣿⣿⣿⣶⣤⡀ ⠹⢿⣿⣷⡄
⢸⣿⣿⣇ ⠸⣿⣿⣷⣦⣼⣿⣿ ⢸⣿⣿⠻⢿⣿⣿⡇ ⠘⣿⣿⣿
⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀ ⢀⣠⣼⣿⣿ ⣿⣿⡇ ⣿⣿⣿⠇
⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿ ⣿⣿⡇ ⣠⣿⣿⣿
⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿ ⣿⣿⣷⣴⣿⣿⣿⠃
⠘⣿⣿⣷ ⠈⠛⢿⣿⣿⣿⠿⠋⠉ ⣠⣴⣿⣿⣿ ⣿⣿⣿⣿⣿⠟⠁
⠻⣿⣿⣿⣤⣀ ⠈⠉ ⢀⣤⣶⣿⣿⣿⠿⠟⠁ ⣰⣿⣿⡟⠋⠁
⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁ ⣠⣼⣿⣿⡟
⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏
⠈⠙⠛⠿⠿⠿⠿⠛⠉
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Implement this plan?
› 1. Yes, implement this plan Switch to Default and start coding
2. Yes, clear context and implement Start a fresh thread (current context: 2% used)
3. No, stay in Plan mode Continue planning with the model
enter select · esc back
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Select Model and Effort
› 1. GPT-6-Luna (current) Fast and affordable model for easier tasks.
2. GPT-5.6-Terra Older balanced model for straightforward work.
3. GPT-5.6-Luna Older fast and efficient model.
enter select · esc back
Loaded 100 of 635 files, more files were not shown because too many files have changed in this diff. Show more