Merge #787: turn the Codeman agent skill on by default

This commit is contained in:
Codeman maintainer committed 2026-11-08 10:41:11 +01:00
commit 8fa2884f48
35 files changed
+1088 -105

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
/**
* @fileoverview The one-time boot migration that lets the 1.50.0 agent skill default reach
* installs that saved App Settings before it (src/web/agent-skill-default-migration.ts).
*
* What is pinned, and why it matters:
* - An absent key, a stored `true` or no settings.json at all: settings.json is not written
* (byte-identical, odd formatting kept) and the marker is set.
* - A stored `false` (what every 1.49 save wrote, chosen or not) becomes `true`, every other key
* kept, exactly once: the log line prints once, and a second start reports `already-done`
* without reading the file.
* - An off set AFTER the migration stays off: through the real `PUT /api/settings` (what the
* page's Save sends), then a second start. The PUT neither writes nor drops the marker, and
* `GET /api/settings` never returns it, so the page cannot hold it to send it back.
* - A settings.json that does not parse is left byte-identical, no marker is written, nothing
* throws, and the next start (after the fix) does the migration.
* - A marker that cannot be checked switches nothing on (a deliberate off must not be cleared
* twice).
* - The marker is not a config-backup file, and `WebServer.start()` runs the migration before
* its first settings read and before it listens (source wiring guard: the boot call is skipped
* in test mode, like the other boot-time writes).
*
* The page's side (a device blob holding the old `false`) is pinned in
* test/agent-skill-setting-ui.test.ts.
*
* Port: 0 (one WebServer for the PUT/GET cases).
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { dataPath } from '../src/config/instance.js';
import { CONFIG_BACKUP_FILES } from '../src/config-backup.js';
import {
AGENT_SKILL_DEFAULT_MARKER,
AGENT_SKILL_DEFAULT_MIGRATED_LOG,
migrateAgentSkillDefault,
} from '../src/web/agent-skill-default-migration.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const MARKER = dataPath(AGENT_SKILL_DEFAULT_MARKER);
/** Written with a formatting no writer of ours produces, so any rewrite shows in the bytes. */
const seedRaw = (text: string) => writeFileSync(SETTINGS_PATH, text);
const seed = (value: unknown) => seedRaw(JSON.stringify(value));
const raw = () => readFileSync(SETTINGS_PATH, 'utf-8');
const stored = () => JSON.parse(raw()) as Record<string, unknown>;
const marker = () => JSON.parse(readFileSync(MARKER, 'utf-8')) as { migratedAt: string; switchedOn: boolean };
/** The bytes of a torn settings.json (test/settings-json-write-serialization.test.ts). */
const TORN = '{\n "agentSkillEnabled": false\n}' + 'rt-1761000000000"\n}';
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
const migratedLogs = () => logSpy.mock.calls.filter(([line]) => line === AGENT_SKILL_DEFAULT_MIGRATED_LOG).length;
beforeEach(() => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
rmSync(SETTINGS_PATH, { force: true });
rmSync(MARKER, { force: true });
logSpy = vi.spyOn(console, 'log');
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
});
describe('nothing to switch on', () => {
it('an absent key: settings.json is not written, the marker is set', async () => {
seed({ lastUsedCase: 'demo' });
const before = raw();
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
expect(raw()).toBe(before);
expect(marker()).toMatchObject({ switchedOn: false });
expect(migratedLogs()).toBe(0);
});
it('no settings.json at all: none is created, the marker is set', async () => {
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
expect(existsSync(SETTINGS_PATH)).toBe(false);
expect(marker()).toMatchObject({ switchedOn: false });
});
it('a stored true is left untouched', async () => {
seed({ agentSkillEnabled: true, caseOrder: ['a'] });
const before = raw();
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
expect(raw()).toBe(before);
expect(marker()).toMatchObject({ switchedOn: false });
});
});
describe('a stored false from before 1.50.0', () => {
it('is switched on once, every other key kept, with one log line', async () => {
seed({ agentSkillEnabled: false, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
expect(stored()).toEqual({ agentSkillEnabled: true, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
expect(marker()).toMatchObject({ switchedOn: true });
expect(Number.isNaN(Date.parse(marker().migratedAt))).toBe(false);
expect(migratedLogs()).toBe(1);
// A second start finds the marker and reads nothing.
const after = raw();
expect(await migrateAgentSkillDefault()).toBe('already-done');
expect(raw()).toBe(after);
expect(migratedLogs()).toBe(1);
});
it('a false written by hand after the migration is never switched on again', async () => {
seed({ agentSkillEnabled: false });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
seed({ agentSkillEnabled: false });
expect(await migrateAgentSkillDefault()).toBe('already-done');
expect(stored()).toEqual({ agentSkillEnabled: false });
});
});
describe('a settings.json that does not parse', () => {
it('is left alone, writes no marker, does not throw, and the next start after the fix migrates', async () => {
seedRaw(TORN);
expect(await migrateAgentSkillDefault()).toBe('refused');
expect(raw()).toBe(TORN);
expect(existsSync(MARKER)).toBe(false);
expect(
warnSpy.mock.calls.some(([line]) => String(line).startsWith('[agent-skill] default migration skipped'))
).toBe(true);
// The user repairs the file (or restores a backup): the migration has not run yet, so it runs now.
seed({ agentSkillEnabled: false });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
expect(stored()).toEqual({ agentSkillEnabled: true });
});
});
describe('a marker that cannot be checked', () => {
it('switches nothing on and writes no marker', async () => {
seed({ agentSkillEnabled: false });
// A path below a regular file: lstat answers ENOTDIR, not ENOENT.
expect(await migrateAgentSkillDefault(join(SETTINGS_PATH, 'marker.json'))).toBe('refused');
expect(stored()).toEqual({ agentSkillEnabled: false });
expect(existsSync(MARKER)).toBe(false);
});
});
describe('with the page and the server (port 0)', () => {
let server: WebServer;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
}, 30000);
it('an off saved through PUT /api/settings after the migration stays off at the next start, and the PUT leaves the marker alone', async () => {
seed({ agentSkillEnabled: false, lastUsedCase: 'demo' });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
const markerBytes = readFileSync(MARKER, 'utf-8');
// What the page sends when the user turns the switch off (plus another synced key).
const res = await fetch(url('/api/settings'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentSkillEnabled: false, autoNameSessions: true }),
});
expect(res.status).toBe(200);
expect(stored()).toEqual({ agentSkillEnabled: false, lastUsedCase: 'demo', autoNameSessions: true });
expect(readFileSync(MARKER, 'utf-8')).toBe(markerBytes);
expect(await migrateAgentSkillDefault()).toBe('already-done');
expect(stored().agentSkillEnabled).toBe(false);
});
it('GET /api/settings never carries the marker, so the page cannot hold or send it', async () => {
seed({ agentSkillEnabled: false });
await migrateAgentSkillDefault();
const res = await fetch(url('/api/settings'));
const body = (await res.json()) as Record<string, unknown>;
const data = (body.success === true ? body.data : body) as Record<string, unknown>;
expect(data).toEqual({ agentSkillEnabled: true });
});
});
describe('placement', () => {
it('the marker is not a config-backup file, so a restore never re-arms or drops it', () => {
expect(CONFIG_BACKUP_FILES as readonly string[]).not.toContain(AGENT_SKILL_DEFAULT_MARKER);
});
it('WebServer.start() runs it before its first settings read, the restore and listen, and drops the settings cache', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
const start = source.slice(source.indexOf(' async start(): Promise<void> {'));
const call = start.indexOf('await migrateAgentSkillDefault()');
expect(call).toBeGreaterThan(0);
for (const later of [
'await this.getAgentInboxPersistEnabled()',
'await this.restoreMuxSessions()',
'await this.app.listen(',
]) {
expect(start.indexOf(later), later).toBeGreaterThan(call);
}
expect(start.slice(call, call + 200)).toContain('this._settingsCache = null;');
});
});
+243
View File
@@ -0,0 +1,243 @@
/**
* @fileoverview The Codeman Agent Skill switch (`agentSkillEnabled`) in App Settings,
* on by default since 1.50.0.
*
* What is pinned, and why it matters:
* - The REAL openAppSettings() reads an ABSENT key as on, like the server's
* getAgentSkillEnabled() (`!== false`). The page and the server must agree: a
* page that showed an absent key as off would write `false` on the next Save of
* any other setting, and the default would be gone without the user touching it.
* - Only an explicit false shows the switch off, wherever it came from (this
* device's blob or the server's settings.json, merged as a synced key).
* - saveAppSettings() sends the switch in the settings PUT, and the body passes the
* .strict() SettingsUpdateSchema (an undeclared key 400s every save).
* - A device that saved on 1.49.0 or earlier keeps `false` in its own blob. After the
* server's one-time boot migration (stored true) its switch shows on and a Save keeps it
* on; on a server without the key the device's copy is dropped for the default; a later
* explicit off on the server stays off. The Save body never carries the migration marker.
* - The row says it is on by default, that the copy stays out of git, and how to
* remove copies already written (turning it off sweeps nothing).
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), with the
* harness of test/tab-cli-logos-setting.test.ts.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
const DESKTOP_KEY = 'codeman-app-settings';
type Settings = Record<string, unknown>;
interface SettingsApp {
openAppSettings(): void;
saveAppSettings(): Promise<void>;
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
_apiPut: ReturnType<typeof vi.fn>;
}
/** Methods that run for real; every other method of the app is a no-op here. */
const REAL = new Set([
'openAppSettings',
'saveAppSettings',
'loadAppSettingsFromServer',
'loadAppSettingsFromStorage',
'saveAppSettingsToStorage',
'getSettingsStorageKey',
'getDefaultSettings',
'resolveTabArrangement',
'resolveTabStateOrder',
// Real so the save builds the body a browser would send (the schema check).
'resolveHeaderStatsStyle',
'resolveSessionSidebarFontSize',
'_displaySettingKeySet',
]);
/** Methods the load/save paths call that other modules add to the prototype. */
const ELSEWHERE = [
'showToast',
'_updateLocalEchoState',
'renderProjectInsightsPanel',
'updateSubagentWindowVisibility',
'showWelcome',
'_applyRunMode',
];
const windows: { close(): void }[] = [];
afterEach(() => {
for (const win of windows.splice(0)) win.close();
});
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
async function boot(stored?: Settings) {
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
windows.push(win);
// After load, so app.js's DOMContentLoaded boot never runs; only the prototype is under test.
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
if (stored) win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
win.eval(
[
`var MobileDetection = {
getDeviceType: () => 'desktop',
isHandheldDevice: () => false,
isMobile: () => false,
isTouchDevice: () => false,
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
'window.__App = CodemanApp;',
].join('\n')
);
const target = Object.create(win.__App.prototype);
target.sessions = new Map();
target.sessionOrder = [];
target._apiPut = vi.fn(async () => ({ ok: true }));
const noop = () => undefined;
for (const name of ELSEWHERE) target[name] = noop;
const app = new Proxy(target, {
get(t, key, receiver) {
const value = Reflect.get(t, key, receiver);
const stubbed =
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
return stubbed ? noop : value;
},
}) as SettingsApp;
const checkbox = () => win.document.getElementById('appSettingsAgentSkill') as HTMLInputElement;
const putBody = () => {
const calls = app._apiPut.mock.calls as [string, Settings][];
const call = calls.find(([path]) => path === '/api/settings');
expect(call, 'a settings PUT').toBeDefined();
return call![1];
};
return { app, checkbox, putBody };
}
describe('the switch loads on by default', () => {
it('an absent key shows on, an explicit false off, an explicit true on', async () => {
for (const [stored, expected] of [
[{}, true],
[{ agentSkillEnabled: false }, false],
[{ agentSkillEnabled: true }, true],
] as const) {
const { app, checkbox } = await boot(stored);
checkbox().checked = !expected;
app.openAppSettings();
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
}
});
it('a fresh device on a server with no stored value shows on', async () => {
const { app, checkbox } = await boot();
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
checkbox().checked = false;
app.openAppSettings();
expect(checkbox().checked).toBe(true);
});
it("the server's explicit false wins over this device's blob (a synced key)", async () => {
const { app, checkbox } = await boot({ agentSkillEnabled: true });
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
app.openAppSettings();
expect(checkbox().checked).toBe(false);
});
});
describe('Save', () => {
it('saving other settings on a never-touched install keeps it on, and the body passes the schema', async () => {
const { app, putBody } = await boot({});
app.openAppSettings();
await app.saveAppSettings();
const body = putBody();
expect(body.agentSkillEnabled).toBe(true);
// The whole body, not just this key: an undeclared key 400s every save.
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
});
it('switching it off sends an explicit false', async () => {
const { app, checkbox, putBody } = await boot({});
app.openAppSettings();
checkbox().checked = false;
await app.saveAppSettings();
expect(putBody().agentSkillEnabled).toBe(false);
});
});
// Every device that saved App Settings on 1.49.0 or earlier holds `agentSkillEnabled: false` in
// its own blob (the switch loaded an absent key as off). The server's boot migration
// (src/web/agent-skill-default-migration.ts) switches a stored false on by storing TRUE, because
// the merge only overwrites a synced key the server has: a key it deleted would leave the
// device's false in place, and the next Save would write it back.
describe('a device that saved before 1.50.0', () => {
const OLD_BLOB = { agentSkillEnabled: false, autoNameSessions: false };
it("after the server's migration (stored true) the switch shows on, and a Save keeps it on and passes the schema", async () => {
const { app, checkbox, putBody } = await boot(OLD_BLOB);
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: true, lastUsedCase: 'demo' }));
app.openAppSettings();
expect(checkbox().checked).toBe(true);
await app.saveAppSettings();
const body = putBody();
expect(body.agentSkillEnabled).toBe(true);
// The migration's marker is a file of its own, never a setting: the Save can neither send it
// back nor be refused for it by the .strict() schema.
expect(Object.keys(body).filter((key) => /migrat/i.test(key))).toEqual([]);
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
});
it('on a server without the key (a save that never reached it) the switch shows the default, on', async () => {
const { app, checkbox, putBody } = await boot(OLD_BLOB);
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
app.openAppSettings();
expect(checkbox().checked).toBe(true);
await app.saveAppSettings();
expect(putBody().agentSkillEnabled).toBe(true);
});
it('an off the user saved after the migration stays off on every device', async () => {
const { app, checkbox, putBody } = await boot({ agentSkillEnabled: true });
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
app.openAppSettings();
expect(checkbox().checked).toBe(false);
await app.saveAppSettings();
expect(putBody().agentSkillEnabled).toBe(false);
});
it('without a server answer the device keeps what it has', async () => {
const { app, checkbox } = await boot(OLD_BLOB);
await app.loadAppSettingsFromServer(Promise.resolve(null as unknown as Settings));
app.openAppSettings();
expect(checkbox().checked).toBe(false);
});
});
describe('the App Settings row', () => {
const doc = new JSDOM(INDEX).window.document;
const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement;
const desc = row.querySelector('.set-row-desc')!.textContent!;
it('is a synced switch', () => {
expect((doc.getElementById('appSettingsAgentSkill') as HTMLInputElement).type).toBe('checkbox');
expect(row.closest('.set-group')!.querySelector('.set-scope')!.textContent).toBe('synced');
});
it('says it is on by default, that git does not list the copy, and how to remove copies', () => {
expect(desc).toContain('On by default');
expect(desc).toContain('ignores itself in git');
expect(desc).toContain('codeman skill uninstall --case <name>');
});
});
+2
View File
@@ -81,6 +81,8 @@ describe('applyAgentSkill with a directory', () => {
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
// It ignores itself in git there too, like the Claude Code copy.
expect(await readFile(join(skillDir, '.gitignore'), 'utf-8')).toBe('*\n');
expect(existsSync(join(casePath, '.claude'))).toBe(false);
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
});
+168 -1
View File
@@ -7,12 +7,14 @@
* publish without them would be caught here before the `files` entry silently
* ignores the missing directory.
*
* Pure filesystem tests in a per-test temp dir. Port: N/A.
* Filesystem tests in a per-test temp dir; the self-ignoring block also runs real
* `git` there (no global or system config). Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import {
@@ -92,6 +94,8 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
// Not even the ignore file: a foreign copy is the user's to track or not.
expect(existsSync(join(skillDir(), '.gitignore'))).toBe(false);
});
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
@@ -107,9 +111,61 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
});
// A cloned repository can commit `reference` as a link, and a Docker case's container
// can plant one in the bind-mounted workspace the host injects into (on by default).
it('refuses a symlinked reference/ folder inside the copy, so nothing is written where it points', async () => {
const outside = join(casePath, 'outside');
await mkdir(outside, { recursive: true });
await writeFile(join(outside, 'history.md'), 'my notes\n');
await mkdir(skillDir(), { recursive: true });
await symlink(outside, join(skillDir(), 'reference'));
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
expect(await readdir(outside)).toEqual(['history.md']);
expect(await readFile(join(outside, 'history.md'), 'utf-8')).toBe('my notes\n');
// Refused before the first write: no SKILL.md, no ignore file.
expect(await readdir(skillDir())).toEqual(['reference']);
});
it('refuses a symlinked reference/ folder planted beside an existing Codeman copy', async () => {
await applyAgentSkill(casePath, true);
const outside = join(casePath, 'outside');
await mkdir(outside, { recursive: true });
await writeFile(join(outside, 'recipes.md'), 'my recipes\n');
await rm(join(skillDir(), 'reference'), { recursive: true });
await symlink(outside, join(skillDir(), 'reference'));
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
expect(await readdir(outside)).toEqual(['recipes.md']);
expect(await readFile(join(outside, 'recipes.md'), 'utf-8')).toBe('my recipes\n');
// The stale copy is left as found, not refreshed through the link.
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).not.toBe(skillMd);
});
});
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
it('refuses to delete through a symlinked reference/ folder inside the copy', async () => {
await applyAgentSkill(casePath, true);
const outside = join(casePath, 'outside');
await mkdir(outside, { recursive: true });
// Files named like the packaged reference files: an unlink through the link would take them.
for (const name of await readdir(join(skillDir(), 'reference'))) {
await writeFile(join(outside, name), 'not yours\n');
}
const names = (await readdir(outside)).sort();
expect(names.length).toBeGreaterThan(0);
await rm(join(skillDir(), 'reference'), { recursive: true });
await symlink(outside, join(skillDir(), 'reference'));
expect(await applyAgentSkill(casePath, false)).toBe('symlink');
expect((await readdir(outside)).sort()).toEqual(names);
for (const name of names) expect(await readFile(join(outside, name), 'utf-8')).toBe('not yours\n');
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
});
it('removes our copy and prunes the emptied directories', async () => {
await applyAgentSkill(casePath, true);
expect(await applyAgentSkill(casePath, false)).toBe('removed');
@@ -139,6 +195,117 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
// The user's file and the directories holding it survive.
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
// And so does the ignore file, so their notes stay out of `git status` as before.
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
});
});
describe('the injected copy ignores itself in git', () => {
const ENV = {
...process.env,
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (...args: string[]) =>
execFileSync('git', args, { cwd: casePath, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
/** Exit status of `git check-ignore -q -- <path>`: 0 ignored, 1 not ignored. */
const checkIgnore = (path: string): number => {
try {
git('check-ignore', '-q', '--', path);
return 0;
} catch (err) {
return (err as { status: number }).status;
}
};
/** A repository with one commit and a tracked `.gitignore` of its own. */
const initRepo = async () => {
git('init', '-q', '-b', 'main');
await writeFile(join(casePath, 'README.md'), 'hello\n');
await writeFile(join(casePath, '.gitignore'), 'node_modules/\n');
git('add', 'README.md', '.gitignore');
git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init');
};
it('writes a .gitignore of `*` into the copy on install', async () => {
expect(await applyAgentSkill(casePath, true)).toBe('installed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
// Never part of the skill content: a second run has nothing to do.
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
});
it('keeps `git status` of the user repository clean, and leaves its own ignore file alone', async () => {
await initRepo();
await applyAgentSkill(casePath, true);
await applyAgentSkill(casePath, true, '.agents/skills');
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
expect(await readFile(join(casePath, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
// Nor the repository's local exclude file (git's template may or may not create one).
const exclude = join(casePath, '.git', 'info', 'exclude');
if (existsSync(exclude)) expect(await readFile(exclude, 'utf-8')).not.toContain('codeman');
});
it('ignores the copy contents, never the folder above it (Claude Code checks only those)', async () => {
// Claude Code 2.1.319 skips a nested skills folder when `git check-ignore` says its
// project directory is ignored; it never checks a skill folder or its SKILL.md.
await initRepo();
await applyAgentSkill(casePath, true);
expect(checkIgnore('.claude/skills/codeman/SKILL.md')).toBe(0);
expect(checkIgnore('.claude/skills/codeman')).toBe(1);
expect(checkIgnore('.claude/skills')).toBe(1);
expect(checkIgnore('.claude')).toBe(1);
});
it('never overwrites a .gitignore that is already in the folder', async () => {
await applyAgentSkill(casePath, true);
await writeFile(join(skillDir(), '.gitignore'), '# mine\nSKILL.md\n');
// A stale copy: the refresh rewrites the skill but not the ignore file.
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('# mine\nSKILL.md\n');
});
it('a copy injected before the ignore file existed gains it on the next install', async () => {
await applyAgentSkill(casePath, true);
await rm(join(skillDir(), '.gitignore'));
// Every skill file is current, so only the ignore file is new: still a refresh.
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
});
it('does not write through a symlink planted at the ignore file name', async () => {
await mkdir(skillDir(), { recursive: true });
const outside = join(casePath, 'outside.txt');
await writeFile(outside, 'untouched\n');
await symlink(outside, join(skillDir(), '.gitignore'));
expect(await applyAgentSkill(casePath, true)).toBe('installed');
expect(await readFile(outside, 'utf-8')).toBe('untouched\n');
});
it('uninstall removes the ignore file with the copy, so no folder is left behind', async () => {
await initRepo();
await applyAgentSkill(casePath, true);
expect(await applyAgentSkill(casePath, false)).toBe('removed');
expect(existsSync(skillDir())).toBe(false);
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
});
it("uninstall keeps an ignore file that is not Codeman's", async () => {
await applyAgentSkill(casePath, true);
await writeFile(join(skillDir(), '.gitignore'), 'SKILL.md\n');
expect(await applyAgentSkill(casePath, false)).toBe('removed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('SKILL.md\n');
});
it('the packaged source carries no ignore file (the plugin mirror pins its file set)', () => {
expect(existsSync(join(process.cwd(), 'skills', 'codeman', '.gitignore'))).toBe(false);
expect(existsSync(join(process.cwd(), 'plugins', 'codeman', 'skills', 'codeman', '.gitignore'))).toBe(false);
});
});
+9 -3
View File
@@ -1,10 +1,16 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
// a create writes the workspace hooks and the agent skill (on by default) into its
// working dir, and those would outlive the run there.
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-edge-cases-'));
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
describe('Edge Cases and Error Handling', () => {
let server: WebServer;
@@ -279,7 +285,7 @@ describe('Concurrent Session Handling', () => {
fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
}).then((r) => r.json())
);
@@ -312,7 +318,7 @@ describe('Concurrent Session Handling', () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const createData = await createRes.json();
expect(createData.success).toBe(true);
+4 -4
View File
@@ -128,10 +128,10 @@ export function createMockRouteContext(options?: {
getModelConfig: vi.fn(async () => null),
getClaudeModeConfig: vi.fn(async () => ({})),
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
// Default OFF mirrors the shipped setting, so existing tests never touch a
// case's .claude/skills. Overridable per test because the create-time
// injection call sites are otherwise unreachable from a route test.
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
// Default ON mirrors the shipped setting (since 1.50.0), so a route test sees
// what a user sees. Writes land in the test's temp working dir, never in a real
// repo. Overridable per test, so the OFF branch stays reachable.
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? true),
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
+10 -3
View File
@@ -13,9 +13,16 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { mkdtempSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
// a create writes the workspace hooks and the agent skill (on by default) into its
// working dir, and those would outlive the run there.
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-lightspeed-'));
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
// Helper to parse SSE events from raw text
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
const events: Array<{ event: string; data: unknown }> = [];
@@ -86,7 +93,7 @@ async function createSession(baseUrl: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const data = await res.json();
if (!data.data?.session?.id) {
@@ -1226,7 +1233,7 @@ describe('Operation Lightspeed', () => {
fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
}).then((r) => r.json())
)
);
+60 -32
View File
@@ -174,10 +174,13 @@ describe('Session Management', () => {
describe('POST /api/sessions', () => {
it('should create a new session', async () => {
// Under the test HOME, never the machine's real `/tmp`: a create writes the workspace
// hooks and the agent skill (on by default) into its working dir.
const workingDir = mkdtempSync(join(TEST_HOME, 'work-'));
const response = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir }),
});
const data = await response.json();
@@ -185,7 +188,7 @@ describe('Session Management', () => {
expect(data.success).toBe(true);
expect(data.data.session).toBeDefined();
expect(data.data.session.id).toBeDefined();
expect(data.data.session.workingDir).toBe('/tmp');
expect(data.data.session.workingDir).toBe(workingDir);
expect(data.data.session.status).toBe('idle');
});
});
@@ -353,49 +356,63 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
}
});
it('does not inject by default, accepts the setting via PUT, then injects on quick-start', async () => {
// 1. Default OFF: a claude quick-start creates the case without the skill.
const offCase = 'test-skill-off-' + Date.now();
createdCases.push(offCase);
const offResponse = await fetch(`${baseUrl}/api/quick-start`, {
// The tests below run in order against one server and one settings.json: absent key
// (on), a partial PUT that omits it (still on), then an explicit false (off).
const skillDirOf = (caseName: string) => join(CASES_DIR, caseName, '.claude', 'skills', 'codeman');
async function quickStart(prefix: string, mode?: string): Promise<string> {
const caseName = `${prefix}-${Date.now()}`;
createdCases.push(caseName);
const response = await fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: offCase }),
body: JSON.stringify(mode ? { caseName, mode } : { caseName }),
});
const offData = await offResponse.json();
expect(offData.success).toBe(true);
expect(existsSync(join(CASES_DIR, offCase, '.claude', 'skills', 'codeman'))).toBe(false);
expect((await response.json()).success).toBe(true);
return caseName;
}
// 2. The `.strict()` settings schema accepts the new synced key.
const putResponse = await fetch(`${baseUrl}/api/settings`, {
async function putSettings(body: Record<string, unknown>): Promise<void> {
const response = await fetch(`${baseUrl}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentSkillEnabled: true }),
body: JSON.stringify(body),
});
const putData = await putResponse.json();
expect(putData.success).toBe(true);
// 3. The server's settings read is cached ~2s; outwait it so the create sees the toggle.
expect((await response.json()).success).toBe(true);
// The server's settings read is cached ~2s; outwait it so the next create sees the change.
await new Promise((resolve) => setTimeout(resolve, 2100));
}
// 4. Quick-start now injects the marker-carrying skill into the new case.
const onCase = 'test-skill-on-' + Date.now();
createdCases.push(onCase);
const onResponse = await fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: onCase }),
});
const onData = await onResponse.json();
expect(onData.success).toBe(true);
/** What settings.json holds, read back through GET /api/settings (enveloped or not). */
async function storedSettings(): Promise<Record<string, unknown>> {
const body = (await (await fetch(`${baseUrl}/api/settings`)).json()) as Record<string, unknown>;
const settings = (body.success === true ? body.data : body) as Record<string, unknown>;
// Not vacuous: every quick-start above records the case it used.
expect(settings).toHaveProperty('lastUsedCase');
return settings;
}
it('injects by default: a settings.json without the key means on', async () => {
const caseName = await quickStart('test-skill-default');
// A quick-start writes lastUsedCase, never this key.
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
const skillDir = join(CASES_DIR, onCase, '.claude', 'skills', 'codeman');
const { readFileSync } = await import('node:fs');
const skillMd = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8');
const skillMd = readFileSync(join(skillDirOf(caseName), 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain('<!-- codeman-managed-agent-skill');
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(join(skillDir, 'reference', 'recipes.md'))).toBe(true);
expect(existsSync(join(skillDirOf(caseName), 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(join(skillDirOf(caseName), 'reference', 'recipes.md'))).toBe(true);
// The copy ignores itself in git, so the default leaves a repository's status clean.
expect(readFileSync(join(skillDirOf(caseName), '.gitignore'), 'utf-8')).toBe('*\n');
}, 30000);
it('a partial settings PUT that omits the key keeps it on', async () => {
await putSettings({ autoNameSessions: false });
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
const caseName = await quickStart('test-skill-partial');
expect(existsSync(join(skillDirOf(caseName), 'SKILL.md'))).toBe(true);
}, 30000);
it('does not inject for shell-mode quick-start even when enabled', async () => {
@@ -410,4 +427,15 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
expect(data.success).toBe(true);
expect(existsSync(join(CASES_DIR, shellCase, '.claude', 'skills', 'codeman'))).toBe(false);
});
it('an explicit false turns it off, and a later partial PUT leaves it off', async () => {
// The `.strict()` settings schema accepts the synced key.
await putSettings({ agentSkillEnabled: false });
await putSettings({ autoNameSessions: true });
expect((await storedSettings()).agentSkillEnabled).toBe(false);
const caseName = await quickStart('test-skill-off');
expect(existsSync(join(CASES_DIR, caseName))).toBe(true);
expect(existsSync(skillDirOf(caseName))).toBe(false);
}, 30000);
});
+24 -2
View File
@@ -2,8 +2,9 @@
* @fileoverview Agent-skill injection on POST /api/sessions (docs/agent-control-plan.md §2).
*
* The quick-start half of this is covered end-to-end in test/quick-start.test.ts;
* the plain create path had NO coverage, because the shared mock context hardcoded
* `getAgentSkillEnabled` to false and nothing could flip it. This exercises the real
* the plain create path had NO coverage, because the shared mock context once hardcoded
* `getAgentSkillEnabled` to false and nothing could flip it (it now defaults to true,
* the shipped default since 1.50.0, and takes an override). This exercises the real
* `applyAgentSkill` against a temp working dir, so it asserts bytes on disk rather
* than a spy call.
*
@@ -81,6 +82,27 @@ describe('POST /api/sessions agent-skill injection', () => {
// remove what this create wrote.
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(true);
// The copy ignores itself, so the default leaves the user's `git status` clean.
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
});
it('injects with the shared mock context left at its default (the shipped default is ON)', async () => {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
harness = { app, ctx };
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-default', mode: 'claude', workingDir },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
});
it('leaves the working dir untouched when the gate is OFF', async () => {
+12 -6
View File
@@ -1,15 +1,20 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdirSync } from 'node:fs';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { WebServer } from '../src/web/server.js';
describe('Interactive Session Lifecycle', () => {
let server: WebServer;
let baseUrl: string;
let customDir: string;
beforeAll(async () => {
// The 'custom working directory' test creates a session in /tmp/test; workingDir
// validation requires the dir to exist, so ensure it does (idempotent, CI-safe).
mkdirSync('/tmp/test', { recursive: true });
// The 'custom working directory' test creates a session in a folder of its own
// (workingDir validation requires it to exist), never a fixed path in the machine's
// real `/tmp`: a create writes the workspace hooks and the agent skill (on by
// default) into its working dir, and those would outlive the run there.
customDir = mkdtempSync(join(tmpdir(), 'codeman-session-test-'));
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
@@ -17,6 +22,7 @@ describe('Interactive Session Lifecycle', () => {
afterAll(async () => {
await server.stop();
rmSync(customDir, { recursive: true, force: true });
}, 60000); // Extended timeout for cleanup
describe('Session Creation', () => {
@@ -39,13 +45,13 @@ describe('Interactive Session Lifecycle', () => {
const response = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp/test' }),
body: JSON.stringify({ workingDir: customDir }),
});
const data = await response.json();
expect(data.success).toBe(true);
expect(data.data.session.workingDir).toBe('/tmp/test');
expect(data.data.session.workingDir).toBe(customDir);
});
});
+11 -4
View File
@@ -2,9 +2,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { EventEmitter } from 'node:events';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { mkdtempSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
// a create writes the workspace hooks and the agent skill (on by default) into its
// working dir, and those would outlive the run there.
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-events-'));
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
// Helper to parse SSE events
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
const events: Array<{ event: string; data: unknown }> = [];
@@ -130,7 +137,7 @@ describe('SSE Events', () => {
await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
// Wait a bit for the event
@@ -168,7 +175,7 @@ describe('SSE Events', () => {
await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const response = await fetch(`${baseUrl}/api/status`);
@@ -221,7 +228,7 @@ describe('SSE Event Types', () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const createData = await createRes.json();
+14 -7
View File
@@ -1,9 +1,16 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { mkdtempSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
// a create writes the workspace hooks and the agent skill (on by default) into its
// working dir, and those would outlive the run there.
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-filter-'));
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
// Helper to parse SSE events from raw text
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
const events: Array<{ event: string; data: unknown }> = [];
@@ -157,7 +164,7 @@ describe('SSE Subscription Filtering', () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const createData = await createRes.json();
const sessionId = createData.data.session.id;
@@ -208,7 +215,7 @@ describe('SSE Subscription Filtering', () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const createData = await createRes.json();
const sessionId = createData.data.session.id;
@@ -263,14 +270,14 @@ describe('SSE Subscription Filtering', () => {
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const session1 = (await createRes1.json()).data.session;
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const session2 = (await createRes2.json()).data.session;
@@ -330,14 +337,14 @@ describe('SSE Subscription Filtering', () => {
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const session1 = (await createRes1.json()).data.session;
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const session2 = (await createRes2.json()).data.session;