Files
Codeman/test/routes/session-routes-agent-skill.test.ts
T
Codeman maintainer b3f0ba9e29 feat(agent-skill): turn the Codeman agent skill on by default
An absent `agentSkillEnabled` now means on, on the server
(`getAgentSkillEnabled()` reads `!== false`, like `workspaceHooksEnabled`)
and on the page (the App Settings switch loads `!== false`, so a Save of any
other setting no longer writes the old default back). Only an explicit false
turns it off. A partial settings PUT that omits the key leaves it as stored,
since the route merges the request into the file.

The switch's help text says it is on by default, that the copy stays out of
git, and that turning it off removes no copy already written. The shared
route-test mock now defaults to on too, so route tests see what a user sees.

Tests: quick-start against a real server (absent key injects, a partial PUT
keeps it on, an explicit false stays off through a later partial PUT), the
real openAppSettings/saveAppSettings in JSDOM (absent shows on, explicit false
shows off, a Save on a never-touched install sends true and passes the
schema), and the plain create route at the mock's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:42:56 +01:00

205 lines
7.8 KiB
TypeScript

/**
* @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 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.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { resolveCliLaunchError } from '../../src/utils/cli-launcher.js';
// External CLIs are pre-flighted (is the binary there?) before anything touches the
// workspace; CI has none installed, so the pre-flight is stubbed to "can start" and one
// test flips it to a refusal.
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(agentSkillEnabled: boolean): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ agentSkillEnabled });
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions agent-skill injection', () => {
let workingDir: string;
let harness: Harness | undefined;
const skillDir = () => join(workingDir, '.claude', 'skills', 'codeman');
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-create-skill-'));
});
afterEach(async () => {
await harness?.app.close();
harness = undefined;
await rm(workingDir, { recursive: true, force: true });
});
it('injects the packaged skill into the working dir when the gate is ON', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-on', mode: 'claude', workingDir },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.getAgentSkillEnabled).toHaveBeenCalled();
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
// The marker is what makes the copy ours: without it, uninstall refuses to
// 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 () => {
harness = await createHarness(false);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-off', mode: 'claude', workingDir },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.getAgentSkillEnabled).toHaveBeenCalled();
expect(existsSync(skillDir())).toBe(false);
});
it('does not inject for a non-claude mode even when the gate is ON', async () => {
// `.claude/skills/` is read by Claude Code only, so the gate is never even
// consulted for the other backends.
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-shell', mode: 'shell', workingDir },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
expect(existsSync(skillDir())).toBe(false);
});
it('gives a codex session the skill in .agents/skills, where codex looks, and none in .claude', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex', mode: 'codex', workingDir },
});
expect(res.statusCode).toBe(200);
const skillMd = await readFile(join(workingDir, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(workingDir, '.agents', 'skills', 'codeman', 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(skillDir())).toBe(false);
// The preamble cache is seeded for it too, so the skill's §0 is the two-line loader
// in codex as in claude (nothing in the preamble is claude-specific).
const sessionId = (JSON.parse(res.body) as { session: { id: string } }).session.id;
const preamble = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), `codeman-agent-${sessionId}.sh`);
expect(existsSync(preamble)).toBe(true);
});
it('gives a pi session nothing: a project skill would put up pi\'s trust prompt', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-pi', mode: 'pi', workingDir },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
expect(existsSync(join(workingDir, '.pi'))).toBe(false);
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
});
it('writes nothing when the CLI cannot start (pre-flight refusal)', async () => {
vi.mocked(resolveCliLaunchError).mockResolvedValueOnce('codex not found');
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex-missing', mode: 'codex', workingDir },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
});
it('defaults an omitted mode to claude and injects', async () => {
// The frontend omits `mode` for a plain claude create, so the default branch
// is the common path, not an edge case.
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-default-mode', workingDir },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
});
});