mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
From the review of #557: - An id shorter than 8 characters refuses with exit 4 before any request, on every verb. `rm 9` resolved to whichever session was alone with that first character (the user's own tab included) and deleted it. Same floor as the server's PARENT_SESSION_ID_MIN_PREFIX. `rm` no longer claims a lineage check: "Delete any session except this one". - `wait --match` help and the README example say the marker must not appear verbatim in the prompt (its echo matches at once) and show the split form. - `send` reads the route's wake-on-LAN answers: `buffered` gets its own line (exit 0), `dropped` exits 1 instead of printing "accepted". - `--` for a prompt that starts with "-", in the `send` description and in the one-argument refusal. - `stripAnsi` builds on the shared one (OSC sequences go too); the inputRefusal JSDoc sits above inputRefusal again. - docs/wiki/Driving-Codeman-From-An-Agent.md gets a `codeman agent` section. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
712 lines
34 KiB
TypeScript
712 lines
34 KiB
TypeScript
/**
|
||
* @fileoverview `codeman agent …` — the three invariants from `src/cli-agent.ts`
|
||
* plus every verb against a recording fake transport, and the real HTTP transport
|
||
* against a local server (headers, auth, query encoding).
|
||
*/
|
||
|
||
import http from 'node:http';
|
||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { Command } from 'commander';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import {
|
||
AgentGuardError,
|
||
EXIT,
|
||
agentInterrupt,
|
||
agentLs,
|
||
agentRead,
|
||
agentRm,
|
||
agentSend,
|
||
agentSpawn,
|
||
agentWait,
|
||
baseHeaders,
|
||
sendPromptFromArgs,
|
||
composerReadyMark,
|
||
buildInterruptBody,
|
||
buildSendBody,
|
||
deleteRefusal,
|
||
describeFailure,
|
||
httpRequest,
|
||
inputRefusal,
|
||
isSelfSession,
|
||
MIN_ID_PREFIX_LENGTH,
|
||
parsePositiveInt,
|
||
registerAgentCommands,
|
||
resolveAgentContext,
|
||
stripAnsi,
|
||
waitExitCode,
|
||
type AgentContext,
|
||
type AgentDeps,
|
||
type ApiResponse,
|
||
type RequestOptions,
|
||
} from '../src/cli-agent.js';
|
||
import { readCodemanEnvFile } from '../src/codeman-credentials.js';
|
||
|
||
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
|
||
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
|
||
|
||
function ctx(overrides: Partial<AgentContext> = {}): AgentContext {
|
||
return { apiUrl: 'http://127.0.0.1:1', selfId: SELF, ...overrides };
|
||
}
|
||
|
||
/** Recording transport: answers from a queue (or a resolver) and keeps every call. */
|
||
function fakeDeps(
|
||
answer: ((options: RequestOptions) => ApiResponse) | ApiResponse[],
|
||
json = false
|
||
): AgentDeps & { calls: RequestOptions[]; out: string[]; err: string[] } {
|
||
const calls: RequestOptions[] = [];
|
||
const out: string[] = [];
|
||
const err: string[] = [];
|
||
const queue = Array.isArray(answer) ? [...answer] : undefined;
|
||
return {
|
||
ctx: ctx(),
|
||
calls,
|
||
out,
|
||
err,
|
||
json,
|
||
now: () => 1_700_000_000_000,
|
||
io: { out: (l) => out.push(l), err: (l) => err.push(l) },
|
||
request: async (_c, options) => {
|
||
calls.push(options);
|
||
if (queue) {
|
||
const next = queue.shift();
|
||
if (!next) throw new Error('fake transport: no answer queued');
|
||
return next;
|
||
}
|
||
return (answer as (o: RequestOptions) => ApiResponse)(options);
|
||
},
|
||
};
|
||
}
|
||
|
||
function ok(data: unknown): ApiResponse {
|
||
return { status: 200, json: { success: true, data }, text: '' };
|
||
}
|
||
|
||
function apiError(status: number, errorCode: string, error: string): ApiResponse {
|
||
return { status, json: { success: false, errorCode, error }, text: '' };
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Invariant 1: the guard
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('resolveAgentContext (guard)', () => {
|
||
const inside = { CODEMAN_MUX: '1', CODEMAN_API_URL: 'http://127.0.0.1:3459', CODEMAN_SESSION_ID: SELF };
|
||
|
||
it('refuses outside a Codeman session', () => {
|
||
expect(() => resolveAgentContext({}, () => ({}))).toThrow(AgentGuardError);
|
||
expect(() => resolveAgentContext({ ...inside, CODEMAN_MUX: '0' }, () => ({}))).toThrow(/CODEMAN_MUX/);
|
||
});
|
||
|
||
it('never guesses an API URL', () => {
|
||
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: '' }, () => ({}))).toThrow(/refusing to guess/);
|
||
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: undefined }, () => ({}))).toThrow(AgentGuardError);
|
||
});
|
||
|
||
it('needs its own session id to tell self from others', () => {
|
||
expect(() => resolveAgentContext({ ...inside, CODEMAN_SESSION_ID: '' }, () => ({}))).toThrow(/CODEMAN_SESSION_ID/);
|
||
});
|
||
|
||
it('takes the password from the environment first, the .env file second, and none means open', () => {
|
||
expect(
|
||
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_PASSWORD: 'file' })).auth
|
||
).toEqual({
|
||
username: 'admin',
|
||
password: 'pw',
|
||
});
|
||
expect(resolveAgentContext(inside, () => ({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 'file' })).auth).toEqual({
|
||
username: 'joe',
|
||
password: 'file',
|
||
});
|
||
expect(resolveAgentContext(inside, () => ({})).auth).toBeUndefined();
|
||
});
|
||
|
||
it('resolves each field the way attach and the TUI do (one shared order)', () => {
|
||
// Password from the environment, username from the file: joe, not admin.
|
||
expect(
|
||
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_USERNAME: 'joe' })).auth
|
||
).toEqual({ username: 'joe', password: 'pw' });
|
||
});
|
||
|
||
it('reads a hand-authored .env with quotes and export prefixes', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'codeman-agent-env-'));
|
||
try {
|
||
const file = join(dir, '.env');
|
||
writeFileSync(file, '# comment\nexport CODEMAN_USERNAME="joe"\nCODEMAN_PASSWORD=\'s3cret\'\nnot a line\n');
|
||
expect(readCodemanEnvFile(file)).toEqual({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 's3cret' });
|
||
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Invariant 2: send is printable text + \r; ESC lives only in interrupt
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('send transmits printable text only', () => {
|
||
it('refuses every control byte and DEL, naming it', () => {
|
||
expect(inputRefusal('\u0003')).toMatch(/0x03/); // Ctrl+C: opencode's app_exit
|
||
expect(inputRefusal('ls\u001b')).toMatch(/0x1b/);
|
||
expect(inputRefusal('a\u007fb')).toMatch(/0x7f/);
|
||
expect(inputRefusal('two\nlines')).toMatch(/single line/); // not the ESC hint
|
||
expect(inputRefusal('a\tb')).toMatch(/single line/);
|
||
expect(inputRefusal('x\u009bmy')).toMatch(/0x9b/); // 8-bit CSI
|
||
expect(inputRefusal('')).toMatch(/empty/);
|
||
});
|
||
|
||
it('accepts ordinary prompts, including unicode', () => {
|
||
expect(inputRefusal('review the diff in src/, then say DONE_4711')).toBeUndefined();
|
||
expect(inputRefusal('prüfe die Ändërung ❯ ok')).toBeUndefined();
|
||
});
|
||
|
||
it('appends exactly one \\r, or nothing with --no-enter, and never anything else', () => {
|
||
const base = { clientId: 'c', seq: 1 };
|
||
expect(buildSendBody('hi', { ...base, enter: true }).input).toBe('hi\r');
|
||
expect(buildSendBody('hi', { ...base, enter: false }).input).toBe('hi');
|
||
const body = buildSendBody('hi', { ...base, enter: true, wait: 'stop,exit', waitTimeout: 5000 });
|
||
expect(body).toEqual({ input: 'hi\r', useMux: true, clientId: 'c', seq: 1, wait: 'stop,exit', waitTimeout: 5000 });
|
||
expect(buildSendBody('hi', { ...base, enter: true })).not.toHaveProperty('wait');
|
||
});
|
||
|
||
it('interrupt is a bare ESC with no Enter', () => {
|
||
const body = buildInterruptBody('c-interrupt', 7);
|
||
expect(body.input).toBe('\u001b');
|
||
expect(String(body.input)).not.toContain('\r');
|
||
expect(body).toEqual({ input: '\u001b', useMux: true, clientId: 'c-interrupt', seq: 7 });
|
||
});
|
||
|
||
it('agentSend refuses control bytes BEFORE touching the transport', async () => {
|
||
const deps = fakeDeps([]);
|
||
expect(await agentSend(deps, { id: OTHER, text: 'q\u0003', enter: true })).toBe(EXIT.refused);
|
||
expect(deps.calls).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Invariant 3: rm fails closed
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('rm fails closed', () => {
|
||
it('refuses an empty id, a short self id, and a prefix match in either direction', () => {
|
||
expect(deleteRefusal(SELF, '')).toMatch(/empty/);
|
||
expect(deleteRefusal('058ee7', OTHER)).toMatch(/too short/);
|
||
expect(deleteRefusal(SELF, SELF)).toMatch(/is me/);
|
||
expect(deleteRefusal(SELF, SELF.slice(0, 8))).toMatch(/is me/); // 8-char form of me
|
||
expect(deleteRefusal(SELF.slice(0, 8), SELF)).toMatch(/is me/); // Docker's truncated $SELF
|
||
expect(deleteRefusal(SELF, OTHER)).toBeUndefined();
|
||
expect(deleteRefusal(SELF, OTHER.slice(0, 8))).toBeUndefined();
|
||
});
|
||
|
||
it('isSelfSession treats an unprovable self as "maybe me"', () => {
|
||
expect(isSelfSession('short', OTHER)).toBe(true);
|
||
expect(isSelfSession(SELF, '')).toBe(true);
|
||
expect(isSelfSession(SELF, OTHER)).toBe(false);
|
||
});
|
||
|
||
it('agentRm never calls DELETE on a refusal', async () => {
|
||
const deps = fakeDeps([]);
|
||
expect(await agentRm(deps, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
|
||
expect(deps.calls).toEqual([]);
|
||
});
|
||
|
||
it('agentRm deletes a foreign id plainly (killMux default, no query)', async () => {
|
||
const deps = fakeDeps([ok({})]);
|
||
expect(await agentRm(deps, { id: OTHER })).toBe(EXIT.ok);
|
||
expect(deps.calls[0]).toMatchObject({ method: 'DELETE', path: `/api/v1/sessions/${OTHER}` });
|
||
expect(deps.calls[0].query).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Verbs against the fake transport
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('agent ls', () => {
|
||
const sessions = [
|
||
{ id: SELF, mode: 'claude', status: 'busy', name: 'w1-Codeman' },
|
||
{ id: OTHER, mode: 'opencode', status: 'idle', workingDir: '/home/joe/wiki' },
|
||
];
|
||
|
||
it('marks this session and falls back to workingDir for the name', async () => {
|
||
const deps = fakeDeps([ok(sessions)]);
|
||
expect(await agentLs(deps)).toBe(EXIT.ok);
|
||
const text = deps.out.join('\n');
|
||
expect(text).toMatch(/\*\s+058ee7b5\s+claude\s+busy\s+w1-Codeman/);
|
||
expect(text).toMatch(/94990c6d\s+opencode\s+idle\s+\/home\/joe\/wiki/);
|
||
});
|
||
|
||
it('--json is the envelope data plus a self flag', async () => {
|
||
const deps = fakeDeps([ok(sessions)], true);
|
||
await agentLs(deps);
|
||
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; self: boolean }>;
|
||
expect(parsed.map((s) => [s.id.slice(0, 8), s.self])).toEqual([
|
||
['058ee7b5', true],
|
||
['94990c6d', false],
|
||
]);
|
||
});
|
||
|
||
it('surfaces a plain-text 401 as a credentials hint, not a parse error', async () => {
|
||
const deps = fakeDeps([{ status: 401, text: 'Unauthorized' }]);
|
||
expect(await agentLs(deps)).toBe(EXIT.error);
|
||
expect(deps.err.join('')).toMatch(/401.*password/);
|
||
});
|
||
});
|
||
|
||
describe('agent send', () => {
|
||
it('refuses to type into its own composer', async () => {
|
||
const deps = fakeDeps([]);
|
||
expect(await agentSend(deps, { id: SELF.slice(0, 8), text: 'hi', enter: true })).toBe(EXIT.refused);
|
||
expect(deps.calls).toEqual([]);
|
||
});
|
||
|
||
it('fire-and-forget: input + \\r, a fixed clientId per caller, seq from the clock', async () => {
|
||
const deps = fakeDeps([ok({ delivered: true })]);
|
||
expect(await agentSend(deps, { id: OTHER, text: 'say DONE_1', enter: true })).toBe(EXIT.ok);
|
||
expect(deps.calls[0]).toMatchObject({
|
||
method: 'POST',
|
||
path: `/api/v1/sessions/${OTHER}/input`,
|
||
body: { input: 'say DONE_1\r', useMux: true, clientId: 'codeman-agent-cli-058ee7b5', seq: 1_700_000_000_000 },
|
||
});
|
||
expect(deps.calls[0].body).not.toHaveProperty('wait');
|
||
});
|
||
|
||
it('--wait passes the signal list and timeout through and maps the result to an exit code', async () => {
|
||
const stop = fakeDeps([ok({ delivered: true, wait: { signal: 'stop', timedOut: false } })]);
|
||
expect(await agentSend(stop, { id: OTHER, text: 'go', enter: true, wait: 'stop,exit', timeoutMs: 5000 })).toBe(
|
||
EXIT.ok
|
||
);
|
||
expect(stop.calls[0].body).toMatchObject({ wait: 'stop,exit', waitTimeout: 5000 });
|
||
|
||
const timeout = fakeDeps([ok({ delivered: true, wait: { timedOut: true, timeoutMs: 5000 } })]);
|
||
expect(await agentSend(timeout, { id: OTHER, text: 'go', enter: true, wait: true, timeoutMs: 5000 })).toBe(
|
||
EXIT.timeout
|
||
);
|
||
|
||
const dead = fakeDeps([ok({ delivered: true, wait: { signal: 'exit' } })]);
|
||
expect(await agentSend(dead, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
|
||
});
|
||
|
||
it('delivered:false without duplicate is "the bytes went nowhere": exit 3, never a ✓', async () => {
|
||
const deps = fakeDeps([ok({ delivered: false, duplicate: false, wait: { ended: true, signal: null } })]);
|
||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
|
||
expect(deps.out.join('')).not.toMatch(/delivered to/);
|
||
expect(deps.err.join('')).toMatch(/not delivered.*restart/);
|
||
});
|
||
|
||
it('fire-and-forget says "accepted", not "delivered" (the route answers before the write)', async () => {
|
||
const deps = fakeDeps([ok({})]);
|
||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
|
||
expect(deps.out.join('')).toMatch(/accepted for/);
|
||
expect(deps.out.join('')).not.toMatch(/delivered to/);
|
||
});
|
||
|
||
it('a sleeping remote host: `buffered` gets its own line and exit 0, never "accepted"', async () => {
|
||
const deps = fakeDeps([ok({ buffered: true })]);
|
||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
|
||
expect(deps.out.join('')).toMatch(/buffered for .*asleep/);
|
||
expect(deps.out.join('')).not.toMatch(/accepted for/);
|
||
});
|
||
|
||
it('`dropped` (over the wake buffer cap) is a failure: exit 1, nothing claims success', async () => {
|
||
const deps = fakeDeps([ok({ buffered: true, dropped: true })]);
|
||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
|
||
expect(deps.err.join('')).toMatch(/dropped: .*nothing will be typed/);
|
||
expect(deps.out).toEqual([]);
|
||
|
||
const json = fakeDeps([ok({ buffered: true, dropped: true })], true);
|
||
expect(await agentSend(json, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
|
||
expect(JSON.parse(json.out.join(''))).toEqual({ buffered: true, dropped: true });
|
||
});
|
||
|
||
it('reports a tagged duplicate instead of claiming delivery', async () => {
|
||
const deps = fakeDeps([ok({ delivered: false, duplicate: true })]);
|
||
await agentSend(deps, { id: OTHER, text: 'go', enter: true });
|
||
expect(deps.out.join('')).toMatch(/duplicate/);
|
||
});
|
||
});
|
||
|
||
describe('agent wait', () => {
|
||
it('--until goes to /wait and a 400 for a hook-less mode is passed through, not papered over', async () => {
|
||
const deps = fakeDeps([apiError(400, 'INVALID_INPUT', 'until=stop is not available for mode opencode')]);
|
||
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.error);
|
||
expect(deps.calls[0]).toMatchObject({
|
||
method: 'GET',
|
||
path: `/api/v1/sessions/${OTHER}/wait`,
|
||
query: { until: 'stop', timeout: 1000 },
|
||
});
|
||
expect(deps.err.join('')).toMatch(/INVALID_INPUT.*opencode/);
|
||
});
|
||
|
||
it('--match goes to /wait-output with from=buffer by default', async () => {
|
||
const deps = fakeDeps([ok({ wait: { matched: true, match: 'DONE_1', snippet: 'DONE_1' } })]);
|
||
expect(await agentWait(deps, { id: OTHER, match: 'DONE_1', timeoutMs: 1000 })).toBe(EXIT.ok);
|
||
expect(deps.calls[0]).toMatchObject({
|
||
path: `/api/v1/sessions/${OTHER}/wait-output`,
|
||
query: { match: 'DONE_1', from: 'buffer', timeout: 1000 },
|
||
});
|
||
});
|
||
|
||
it('refuses --until together with --match', async () => {
|
||
const deps = fakeDeps([]);
|
||
expect(await agentWait(deps, { id: OTHER, until: 'idle', match: 'x', timeoutMs: 1000 })).toBe(EXIT.refused);
|
||
expect(deps.calls).toEqual([]);
|
||
});
|
||
|
||
it('a wait that ended without an answer is reported as dead, not as `signal: null`', async () => {
|
||
const deps = fakeDeps([ok({ wait: { ended: true, signal: null, timedOut: false } })]);
|
||
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.dead);
|
||
expect(deps.out.join('')).toMatch(/went away/);
|
||
expect(deps.out.join('')).not.toMatch(/signal: null/);
|
||
});
|
||
|
||
it('exit codes: matched/signal 0, timeout 2, exit 3', () => {
|
||
expect(waitExitCode({ signal: 'stop' })).toBe(EXIT.ok);
|
||
expect(waitExitCode({ matched: true })).toBe(EXIT.ok);
|
||
expect(waitExitCode({ matched: false, timedOut: true })).toBe(EXIT.timeout);
|
||
expect(waitExitCode({ timedOut: true })).toBe(EXIT.timeout);
|
||
expect(waitExitCode({ signal: 'exit' })).toBe(EXIT.dead);
|
||
// A worker that dies during --until stop: the registry only satisfies waiters that
|
||
// listed `exit`, then cancels the rest → ended:true, signal:null. Never "done".
|
||
expect(waitExitCode({ ended: true, signal: null, timedOut: false })).toBe(EXIT.dead);
|
||
expect(waitExitCode({ ended: true, matched: false, timedOut: false })).toBe(EXIT.dead);
|
||
expect(waitExitCode(undefined)).toBe(EXIT.error);
|
||
});
|
||
});
|
||
|
||
describe('agent read', () => {
|
||
it('defaults to last-response and prints the text', async () => {
|
||
const deps = fakeDeps([ok({ text: 'the answer', timestamp: 't' })]);
|
||
expect(await agentRead(deps, { id: OTHER })).toBe(EXIT.ok);
|
||
expect(deps.calls[0].path).toBe(`/api/v1/sessions/${OTHER}/last-response`);
|
||
expect(deps.out).toEqual(['the answer']);
|
||
});
|
||
|
||
it('says why an empty transcript is empty instead of printing nothing', async () => {
|
||
const deps = fakeDeps([ok({ text: '' })]);
|
||
await agentRead(deps, { id: OTHER });
|
||
expect(deps.err.join('')).toMatch(/nothing answered yet.*--tail 3000/);
|
||
});
|
||
|
||
it('--tail fetches the terminal and strips ANSI', async () => {
|
||
const deps = fakeDeps([ok({ terminalBuffer: '\u001b]0;w1 title\u0007\u001b[32m❯\u001b[0m ready \u001b(B' })]);
|
||
expect(await agentRead(deps, { id: OTHER, tail: 500 })).toBe(EXIT.ok);
|
||
expect(deps.calls[0]).toMatchObject({ path: `/api/v1/sessions/${OTHER}/terminal`, query: { tail: 500 } });
|
||
expect(deps.out).toEqual(['❯ ready ']);
|
||
});
|
||
|
||
it('--full prints every message with its role', async () => {
|
||
const deps = fakeDeps([
|
||
ok({
|
||
text: 'b',
|
||
messages: [
|
||
{ role: 'user', text: 'a' },
|
||
{ role: 'assistant', text: 'b' },
|
||
],
|
||
}),
|
||
]);
|
||
await agentRead(deps, { id: OTHER, full: true });
|
||
expect(deps.calls[0].query).toMatchObject({ context: 'full' });
|
||
expect(deps.out.map(stripAnsi)).toEqual(['user: a', 'assistant: b']);
|
||
});
|
||
});
|
||
|
||
describe('agent interrupt', () => {
|
||
it('sends the bare ESC body under its own clientId, never to itself', async () => {
|
||
const deps = fakeDeps([ok({ delivered: true })]);
|
||
expect(await agentInterrupt(deps, { id: OTHER })).toBe(EXIT.ok);
|
||
expect(deps.calls[0].body).toEqual({
|
||
input: '\u001b',
|
||
useMux: true,
|
||
clientId: 'codeman-agent-cli-058ee7b5-interrupt',
|
||
seq: 1_700_000_000_000,
|
||
});
|
||
const self = fakeDeps([]);
|
||
expect(await agentInterrupt(self, { id: SELF })).toBe(EXIT.refused);
|
||
expect(self.calls).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('send takes the prompt as ONE argument', () => {
|
||
it('refuses several words, which an unquoted multi-line $(…) becomes after word splitting', () => {
|
||
expect(sendPromptFromArgs(['review src/, then say DONE'])).toEqual({ text: 'review src/, then say DONE' });
|
||
expect(sendPromptFromArgs(['line', 'one', 'line', 'two'])).toMatchObject({
|
||
error: expect.stringMatching(/ONE argument, got 4/),
|
||
});
|
||
// A leading "-" is commander's option syntax, so the hint names the escape.
|
||
expect(sendPromptFromArgs(['a', 'b'])).toMatchObject({ error: expect.stringContaining('send <id> -- "- fix') });
|
||
});
|
||
});
|
||
|
||
describe('agent spawn: a worker that dies during the readiness wait', () => {
|
||
it('is exit 3 with its own line, not the composer-timeout hint', async () => {
|
||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { ended: true, matched: false } })]);
|
||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.dead);
|
||
expect(deps.err.join('')).toMatch(/exited during the readiness wait/);
|
||
expect(deps.err.join('')).not.toMatch(/composer not seen/);
|
||
});
|
||
});
|
||
|
||
describe('agent wait: a timeout is an answer, not a failure', () => {
|
||
it('prints a neutral line and exits 2', async () => {
|
||
const deps = fakeDeps([ok({ wait: { timedOut: true, timeoutMs: 1000 } })]);
|
||
expect(await agentWait(deps, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 })).toBe(EXIT.timeout);
|
||
const line = deps.out.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
||
expect(line).toBe('timed out after 1000 ms (exit 2)');
|
||
expect(deps.err).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('agent spawn', () => {
|
||
it('quick-starts with lineage and waits for the claude composer', async () => {
|
||
const deps = fakeDeps([
|
||
ok({ sessionId: OTHER, caseName: 'scratch-1', casePath: '/x' }),
|
||
ok({ wait: { matched: true } }),
|
||
]);
|
||
expect(await agentSpawn(deps, { caseName: 'scratch-1', mode: 'claude', ready: true, timeoutMs: 2000 })).toBe(
|
||
EXIT.ok
|
||
);
|
||
expect(deps.calls[0]).toMatchObject({
|
||
method: 'POST',
|
||
path: '/api/v1/quick-start',
|
||
body: { caseName: 'scratch-1', mode: 'claude', parentSessionId: SELF },
|
||
});
|
||
expect(deps.calls[1]).toMatchObject({
|
||
path: `/api/v1/sessions/${OTHER}/wait-output`,
|
||
query: { match: 'shift+tab', from: 'buffer', timeout: 2000 },
|
||
});
|
||
expect(deps.out).toEqual([OTHER]); // stdout is the id ALONE, so `SID=$(…)` works; prose goes to stderr
|
||
expect(deps.err.join('')).toMatch(/spawned .*composer up/s);
|
||
});
|
||
|
||
it('labels the case as agent scratch on the spawn request, and on no other request', async () => {
|
||
// The label drives a recursive-delete affordance in the Add Case UI: it may only ride
|
||
// the request that can CREATE a case directory (quick-start), never anything else.
|
||
const spawn = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: true } })]);
|
||
await agentSpawn(spawn, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 });
|
||
expect(spawn.calls[0].headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-agent-cli' });
|
||
expect(spawn.calls[1].headers?.['X-Codeman-Agent-Origin']).toBeUndefined(); // the readiness wait
|
||
|
||
const everyOther = fakeDeps((o) =>
|
||
o.path === '/api/v1/sessions' ? ok([]) : ok({ wait: { signal: 'stop' }, text: '' })
|
||
);
|
||
await agentLs(everyOther);
|
||
await agentSend(everyOther, { id: OTHER, text: 'hi', enter: true });
|
||
await agentWait(everyOther, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 });
|
||
await agentRead(everyOther, { id: OTHER });
|
||
await agentInterrupt(everyOther, { id: OTHER });
|
||
await agentRm(everyOther, { id: OTHER });
|
||
expect(everyOther.calls.length).toBeGreaterThan(5);
|
||
for (const call of everyOther.calls) expect(call.headers?.['X-Codeman-Agent-Origin'], call.path).toBeUndefined();
|
||
expect(baseHeaders(ctx())).not.toHaveProperty('X-Codeman-Agent-Origin');
|
||
});
|
||
|
||
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
|
||
expect(composerReadyMark('claude')).toBe('shift+tab');
|
||
expect(composerReadyMark('deepseek')).toBe('❯');
|
||
expect(composerReadyMark('pi')).toBeUndefined();
|
||
expect(composerReadyMark('no-such-cli')).toBeUndefined();
|
||
});
|
||
|
||
it('a composer that never shows up is exit 2 and the session is left for inspection, not deleted', async () => {
|
||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: false, timedOut: true } })]);
|
||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.timeout);
|
||
expect(deps.calls.map((c) => c.method)).toEqual(['POST', 'GET']);
|
||
expect(deps.err.join('')).toMatch(/startup dialog/);
|
||
});
|
||
|
||
it('a mode without a readiness mark returns after the create, and --no-ready skips the wait everywhere', async () => {
|
||
const pi = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
|
||
expect(await agentSpawn(pi, { caseName: 'c', mode: 'pi', ready: true, timeoutMs: 1000 })).toBe(EXIT.ok);
|
||
expect(pi.calls).toHaveLength(1);
|
||
const noReady = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
|
||
expect(await agentSpawn(noReady, { caseName: 'c', mode: 'claude', ready: false, timeoutMs: 1000 })).toBe(EXIT.ok);
|
||
expect(noReady.calls).toHaveLength(1);
|
||
});
|
||
|
||
it('a failed readiness call reports its own reason instead of the trust-dialog hint', async () => {
|
||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), apiError(429, 'RATE_LIMITED', 'waiter pool full')]);
|
||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||
expect(deps.err.join('')).toMatch(/readiness check failed: RATE_LIMITED/);
|
||
expect(deps.err.join('')).not.toMatch(/trust dialog/);
|
||
expect(deps.out).toEqual([OTHER]); // the session exists; the id is still handed back
|
||
});
|
||
|
||
it('a failed quick-start is terminal: the error code is shown and nothing else is called', async () => {
|
||
const deps = fakeDeps([apiError(409, 'SESSION_BUSY', 'session cap reached')]);
|
||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||
expect(deps.calls).toHaveLength(1);
|
||
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
|
||
});
|
||
});
|
||
|
||
describe('session id prefixes', () => {
|
||
const THIRD = '94990c6d-ffff-4000-8000-000000000000';
|
||
const list = ok([{ id: SELF }, { id: OTHER }, { id: THIRD }]);
|
||
|
||
it('a full id goes straight to the route, no list call', async () => {
|
||
const deps = fakeDeps([ok({ text: 'x' })]);
|
||
await agentRead(deps, { id: OTHER });
|
||
expect(deps.calls.map((c) => c.path)).toEqual([`/api/v1/sessions/${OTHER}/last-response`]);
|
||
});
|
||
|
||
it('a unique prefix (what `ls` prints) resolves through the list — the routes 404 on prefixes', async () => {
|
||
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ text: 'x' })]);
|
||
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.ok);
|
||
expect(deps.calls.map((c) => c.path)).toEqual(['/api/v1/sessions', `/api/v1/sessions/${OTHER}/last-response`]);
|
||
});
|
||
|
||
it('an ambiguous prefix refuses instead of picking one', async () => {
|
||
const deps = fakeDeps([list]);
|
||
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.error);
|
||
expect(deps.err.join('')).toMatch(/ambiguous.*94990c6d-e461.*94990c6d-ffff/);
|
||
expect(deps.calls).toHaveLength(1);
|
||
});
|
||
|
||
it('an unknown prefix names the problem', async () => {
|
||
const deps = fakeDeps([list]);
|
||
expect(await agentRm(deps, { id: 'deadbeef' })).toBe(EXIT.error);
|
||
expect(deps.err.join('')).toMatch(/no session starts with "deadbeef"/);
|
||
expect(deps.calls.map((c) => c.method)).toEqual(['GET']);
|
||
});
|
||
|
||
it('a prefix shorter than 8 characters refuses (exit 4) before any request, on every verb', async () => {
|
||
expect(MIN_ID_PREFIX_LENGTH).toBe(8);
|
||
// The maintainer's repro: `rm 9` with one other session starting with 9 deleted it.
|
||
const rm = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({})]);
|
||
expect(await agentRm(rm, { id: '9' })).toBe(EXIT.refused);
|
||
expect(rm.calls).toEqual([]);
|
||
expect(rm.err.join('')).toMatch(/"9" is shorter than 8 characters.*8-character id `agent ls` prints/);
|
||
|
||
const short = OTHER.slice(0, 7);
|
||
const verbs: Array<[string, (deps: AgentDeps) => Promise<number>]> = [
|
||
['send', (d) => agentSend(d, { id: short, text: 'go', enter: true })],
|
||
['wait', (d) => agentWait(d, { id: short, until: 'idle', timeoutMs: 1000 })],
|
||
['read', (d) => agentRead(d, { id: short })],
|
||
['interrupt', (d) => agentInterrupt(d, { id: short })],
|
||
['rm', (d) => agentRm(d, { id: short })],
|
||
];
|
||
for (const [verb, call] of verbs) {
|
||
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ delivered: true })]);
|
||
expect(await call(deps), verb).toBe(EXIT.refused);
|
||
expect(deps.calls, verb).toEqual([]);
|
||
}
|
||
});
|
||
|
||
it('rm runs the self guard before the list and again on the resolved id', async () => {
|
||
const first = fakeDeps([ok([{ id: SELF }])]);
|
||
expect(await agentRm(first, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
|
||
expect(first.calls).toEqual([]); // refused before any request
|
||
const resolved = fakeDeps([ok([{ id: SELF }, { id: OTHER }])]);
|
||
expect(await agentRm(resolved, { id: 'deadbeef' })).toBe(EXIT.error); // nothing to delete
|
||
expect(resolved.calls.map((c) => c.method)).toEqual(['GET']);
|
||
});
|
||
});
|
||
|
||
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)!;
|
||
|
||
it('--match says the prompt must not contain the marker verbatim, and how to split it', () => {
|
||
const match = sub('wait').options.find((o) => o.long === '--match')!;
|
||
expect(match.description).toMatch(/never put the marker verbatim in the prompt/);
|
||
expect(match.description).toMatch(/WORKDONE followed by _4711.*WORKDONE_4711/);
|
||
});
|
||
|
||
it('send names the -- escape for a prompt that starts with "-"', () => {
|
||
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
|
||
});
|
||
|
||
it('rm does not claim a lineage check it does not make', () => {
|
||
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
|
||
});
|
||
});
|
||
|
||
describe('option parsing', () => {
|
||
it('positive integers only, the server rejects the rest', () => {
|
||
expect(parsePositiveInt(undefined, 60000)).toBe(60000);
|
||
expect(parsePositiveInt('1500', 1)).toBe(1500);
|
||
for (const bad of ['0', '-1', '1.5', '30s', '']) expect(() => parsePositiveInt(bad, 1)).toThrow(/positive integer/);
|
||
});
|
||
|
||
it('describeFailure prefers the envelope and falls back to the status line', () => {
|
||
expect(describeFailure(apiError(404, 'NOT_FOUND', 'no such session'))).toBe(
|
||
'NOT_FOUND: no such session (HTTP 404)'
|
||
);
|
||
expect(describeFailure({ status: 403, text: 'Forbidden: host not allowed' })).toBe(
|
||
'HTTP 403 Forbidden: host not allowed'
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// The real transport against a local server
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('httpRequest', () => {
|
||
let server: http.Server;
|
||
let apiUrl: string;
|
||
const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
|
||
|
||
beforeAll(async () => {
|
||
server = http.createServer((req, res) => {
|
||
const chunks: Buffer[] = [];
|
||
req.on('data', (c: Buffer) => chunks.push(c));
|
||
req.on('end', () => {
|
||
seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() });
|
||
if (req.url?.startsWith('/plain')) {
|
||
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
|
||
return;
|
||
}
|
||
res
|
||
.writeHead(200, { 'Content-Type': 'application/json' })
|
||
.end(JSON.stringify({ success: true, data: { echo: true } }));
|
||
});
|
||
});
|
||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
const address = server.address() as { port: number };
|
||
apiUrl = `http://127.0.0.1:${address.port}`;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||
});
|
||
|
||
it('carries lineage headers, Basic auth and a urlencoded query (the + in shift+tab survives)', async () => {
|
||
const res = await httpRequest(ctx({ apiUrl, auth: { username: 'joe', password: 'pw' } }), {
|
||
method: 'GET',
|
||
path: '/api/v1/sessions/x/wait-output',
|
||
query: { match: 'shift+tab', from: 'buffer', timeout: 1000, nocase: undefined },
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(res.json).toEqual({ success: true, data: { echo: true } });
|
||
const last = seen.at(-1)!;
|
||
expect(last.url).toBe('/api/v1/sessions/x/wait-output?match=shift%2Btab&from=buffer&timeout=1000');
|
||
expect(last.headers['x-codeman-parent-session']).toBe(SELF);
|
||
expect(last.headers['x-codeman-agent-origin']).toBeUndefined(); // only spawn's quick-start carries it
|
||
expect(last.headers.authorization).toBe(`Basic ${Buffer.from('joe:pw').toString('base64')}`);
|
||
});
|
||
|
||
it('posts JSON bodies with a length, and no auth header when the server is open', async () => {
|
||
await httpRequest(ctx({ apiUrl }), {
|
||
method: 'POST',
|
||
path: '/api/v1/sessions/x/input',
|
||
body: { input: 'hi\r', seq: 1 },
|
||
});
|
||
const last = seen.at(-1)!;
|
||
expect(last.method).toBe('POST');
|
||
expect(JSON.parse(last.body)).toEqual({ input: 'hi\r', seq: 1 });
|
||
expect(last.headers['content-type']).toBe('application/json');
|
||
expect(last.headers.authorization).toBeUndefined();
|
||
});
|
||
|
||
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);
|
||
expect(res.json).toBeUndefined();
|
||
expect(res.text).toBe('Unauthorized');
|
||
});
|
||
});
|