chore: version packages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-09 01:18:03 +02:00
parent fa18eeef35
commit d26f26fe34
32 changed files with 7075 additions and 54 deletions
+19
View File
@@ -133,6 +133,25 @@ describe('refreshStaleCodemanHooks', () => {
expect(after.hooks.CustomEvent).toEqual(customEvent);
});
// A case can be current on the secret AND the background-wake hook and still carry
// the `-k`-less curl shape, which exits 60 against a self-signed HTTPS API and is
// swallowed by `|| true` — every hook event dead, silently. The refresh must treat
// that as a third stale shape.
it('heals a current-looking block whose hook curls lack -k (HTTPS self-signed installs)', async () => {
const { generateHooksConfig } = await import('../src/hooks-config.js');
const flagless = JSON.parse(JSON.stringify(generateHooksConfig()).replaceAll('curl -sk ', 'curl -s '));
writeFileSync(settingsPath, JSON.stringify({ hooks: flagless.hooks }, null, 2));
await refreshStaleCodemanHooks(dir);
const after = readFileSync(settingsPath, 'utf-8');
expect(after).toContain('curl -sk -X POST');
expect(after).not.toContain('curl -s -X POST');
// and the pass is convergent: a second refresh must not rewrite
await refreshStaleCodemanHooks(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(after);
});
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
await refreshStaleCodemanHooks(dir);
expect(existsSync(settingsPath)).toBe(false);
+11
View File
@@ -95,6 +95,17 @@ describe('generateHooksConfig', () => {
expect(notifHooks[0].hooks[0].command).toContain('|| true');
});
// On --https/tailscale installs CODEMAN_API_URL is HTTPS with a self-signed cert.
// A `-k`-less hook curl exits 60 there, the `|| true` swallows it, and every hook
// event (stop, permission_prompt, elicitation_dialog, idle_prompt, teammate_idle,
// task_completed) dies silently — killing respawn's idle signals and the wait
// endpoints' stop/blocked. The statusline exporter always carried -k; the hooks must too.
it('every hook curl tolerates a self-signed HTTPS API (curl -sk)', () => {
const serialized = JSON.stringify(generateHooksConfig());
expect(serialized).toContain('curl -sk -X POST');
expect(serialized).not.toContain('curl -s -X POST');
});
it('should set timeout to 10 seconds (hook timeout fields are seconds)', () => {
const config = generateHooksConfig();
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>;
+93
View File
@@ -89,4 +89,97 @@ describe('Stable HTTP contract (live server)', () => {
expect(body.success).toBe(false);
expect(body.errorCode).toBe('INVALID_INPUT');
});
/**
* The agent wait primitives, through the REAL pipeline.
*
* Their own route tests hand-roll a partial copy of the preSerialization hook that
* maps errorCode to status but does NOT wrap bare payloads — so nothing there
* proves these routes emit a correct envelope, a correct status, or work through
* the /api/v1 alias, and one assertion in them pins `{}` for a response no client
* will ever receive. This is the file whose docstring already claims that scope.
*/
describe('agent wait primitives', () => {
let sessionId: string;
beforeAll(async () => {
const res = await fetch(`${base}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
sessionId = (await res.json()).data.session.id;
expect(sessionId).toBeDefined();
});
afterAll(async () => {
await fetch(`${base}/api/sessions/${sessionId}`, { method: 'DELETE' });
});
it('answers a wait timeout as a 200 inside the envelope, on the /api/v1 alias', async () => {
// A timeout is the long-poll SUCCEEDING at "did this happen within N ms?"; a
// 4xx/5xx here would make every poll boundary indistinguishable from a failure.
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=working&timeout=1000`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toBe('no-store');
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.sessionId).toBe(sessionId);
// The one shape all three wait endpoints share.
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.signal).toBeNull();
expect(body.data.wait.timeoutMs).toBe(1000);
expect(body.data.wait.until).toEqual(['working']);
});
it('returns a contract-shaped 400 for an unknown until token', async () => {
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=stpo`);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.success).toBe(false);
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('stpo');
});
it('returns a contract-shaped 400 naming the bad query parameter', async () => {
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?timeout=30s`);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('timeout');
});
it('wraps the non-wait input response as { success: true, data: {} }', async () => {
// What a client actually receives on the fire-and-forget path — NOT the bare
// `{}` the handler returns and the route tests assert.
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'hello' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true, data: {} });
});
it('serves wait-output through the same envelope', async () => {
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait-output?match=NEVER_APPEARS&timeout=1000`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.wait.matched).toBe(false);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.match).toBe('NEVER_APPEARS');
});
it('404s an unknown session on both new routes, with the error envelope', async () => {
for (const path of ['wait?until=idle', 'wait-output?match=x']) {
const res = await fetch(`${base}/api/v1/sessions/nonexistent/${path}`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.success).toBe(false);
expect(body.errorCode).toBe('NOT_FOUND');
}
});
});
});
+21 -3
View File
@@ -4,6 +4,7 @@
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { SessionStatus } from '../../src/types.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -12,8 +13,15 @@ import { vi } from 'vitest';
export class MockSession extends EventEmitter {
id: string;
workingDir: string = '/tmp/test-workdir';
status: 'idle' | 'working' = 'idle';
pid: number = 12345;
/**
* The REAL union, deliberately. This used to be `'idle' | 'working'`, and
* `'working'` is not a `SessionStatus` at all — so `signalForStatus()` fell to its
* `default: null` branch in every route test and the busy / stopped / error halves
* of the immediate-resolve mapping had zero coverage while appearing to be tested.
*/
status: SessionStatus = 'idle';
/** `null` once the PTY is gone (or before it has ever started) — see `pid` in Session. */
pid: number | null = 12345;
isWorking: boolean = false;
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
@@ -113,7 +121,9 @@ export class MockSession extends EventEmitter {
/** Simulate working state with spinner */
simulateWorking(text: string = 'Thinking'): void {
this.simulateTerminalOutput(`${text}... \u280b`);
this.status = 'working';
// 'busy' is what the real Session sets while a turn is in flight; the old
// 'working' here was the event name, not a status value.
this.status = 'busy';
this.emit('working');
}
@@ -182,6 +192,14 @@ export class MockSession extends EventEmitter {
return this._muxName;
}
/**
* Mirrors `Session.usesMux`. True by default because that is the normal
* configuration, and it is what makes a route's pane-liveness probe reachable:
* `session.pid` is the tmux ATTACH CLIENT, so a mux-backed session's worker can be
* dead while `pid` is still a live number.
*/
usesMux: boolean = true;
/** Check for active child processes (mock returns configurable list) */
getActiveChildProcesses(): { pid: number; command: string }[] {
return this._activeChildProcesses;
+671
View File
@@ -0,0 +1,671 @@
/**
* @fileoverview Route tests for the `wait` field on `POST /api/sessions/:id/input`.
*
* This endpoint exists to close a race a caller cannot close from outside: between
* the write landing and the session flipping to `working`, a SEPARATE wait sees the
* session still idle and returns instantly, reporting the previous turn as this one.
* Registering the waiter before the write is the whole point, so that is what the
* first test pins.
*
* It also pins that the historical fire-and-forget path is untouched when `wait` is
* absent, that a capacity rejection gives the dedup seq back (otherwise the caller's
* retry is refused as a duplicate and the input is lost by the very mechanism
* reliable delivery exists for), and that `delivered` reports what actually happened
* to the write rather than merely "this was not a duplicate".
*
* Plan: docs/agent-control-plan.md
*/
import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import type { IncomingMessage } from 'node:http';
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
const SESSION_ID = 'input-wait-session';
const URL = `/api/sessions/${SESSION_ID}/input`;
afterEach(() => {
// Deliberately not `cancelEverything()`: it latches the registry's stopped flag,
// which would leave every later test in this file talking to a dead registry.
sessionWaits.cancelAll(SESSION_ID);
_resetPaneLivenessState();
});
async function harness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext; rawRequests: IncomingMessage[] }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
const rawRequests: IncomingMessage[] = [];
app.addHook('onRequest', async (req) => {
rawRequests.push(req.raw);
});
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, rawRequests };
}
const send = (app: FastifyInstance, payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: URL, payload });
describe('POST /api/sessions/:id/input without wait (unchanged behavior)', () => {
it('returns the historical bare body and registers no waiter', async () => {
const { app } = await harness();
const res = await send(app, { input: 'hello', useMux: true });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('still returns before the mux write settles', async () => {
// The fire-and-forget property is why the response is fast; send-and-wait must
// not have turned every input into an awaited tmux round-trip.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
let resolveWrite: (ok: boolean) => void = () => {};
session.writeViaMux = () => new Promise<boolean>((resolve) => (resolveWrite = resolve));
const res = await send(app, { input: 'hello', useMux: true });
expect(res.json()).toEqual({});
resolveWrite(true);
});
it('a tagged duplicate still returns the bare body', async () => {
const { app } = await harness();
await send(app, { input: 'first', clientId: 'c1', seq: 1 });
const replay = await send(app, { input: 'first', clientId: 'c1', seq: 1 });
expect(replay.json()).toEqual({});
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('a failed direct write is still not an error response', async () => {
// A session can legitimately have no PTY yet, and callers have always been able
// to write to one without a 4xx. Only the `wait` path reports delivery.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const res = await send(app, { input: 'x' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
});
});
describe('POST /api/sessions/:id/input with wait', () => {
it('registers the waiter BEFORE the write, so the pre-existing idle state cannot satisfy it', async () => {
// The mock session is idle. A naive send-then-wait would answer immediately with
// that stale idle; this must block until a real transition.
const { app } = await harness();
const pending = send(app, { input: 'run the tests', useMux: true, wait: true });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.delivered).toBe(true);
expect(body.data.duplicate).toBe(false);
expect(body.data.wait.signal).toBe('stop');
expect(body.data.wait.immediate).toBe(false);
expect(body.data.wait.timedOut).toBe(false);
});
it('uses the same data.wait envelope as the two GET endpoints', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
const { data } = (await pending).json();
expect(Object.keys(data).sort()).toEqual(['delivered', 'duplicate', 'limitPaused', 'status', 'wait']);
expect(data.status).toBe('idle');
expect(data.limitPaused).toBe(false);
expect(data.wait.aborted).toBe(false);
});
it('echoes the effective timeout after clamping', async () => {
// The schema accepts up to 24h; the server caps at MAX_WAIT_MS. Without the echo
// an agent reads the cap as "my 24h wait elapsed" and kills a healthy worker.
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 86_400_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
expect((await pending).json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('delivers the input before blocking', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const pending = send(app, { input: 'echo hi', useMux: true, wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
// The write happened while the request is still open.
expect(session.writeBuffer.join('')).toContain('echo hi');
sessionWaits.notifySignal(SESSION_ID, 'stop');
await pending;
});
it('wait: true uses the default signal set', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: true });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'idle');
expect((await pending).json().data.wait.until).toEqual(['stop', 'idle', 'exit']);
});
it('accepts an explicit signal list', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'exit' });
await new Promise((resolve) => setTimeout(resolve, 20));
// Not one of the requested signals: the wait must not resolve on it.
expect(sessionWaits.notifySignal(SESSION_ID, 'idle')).toBe(0);
sessionWaits.notifySignal(SESSION_ID, 'exit');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.until).toEqual(['exit']);
});
it('accepts an array, the same grammar the query parameter takes', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: ['stop', 'exit'] });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'exit');
const body = (await pending).json();
expect(body.data.wait.until).toEqual(['stop', 'exit']);
expect(body.data.wait.signal).toBe('exit');
});
it('rejects an unknown wait signal without writing', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const before = session.writeBuffer.length;
const res = await send(app, { input: 'x', wait: 'stpo' });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.writeBuffer.length).toBe(before);
});
it('rejects a hook-only signal for external CLI modes', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await send(app, { input: 'x', wait: 'stop' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('codex');
});
it('rejects a hook-only signal for a shell session too', async () => {
// Not an external CLI, but a plain bash PTY installs no hooks either, so `stop`
// could only ever time out.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
const res = await send(app, { input: 'x', wait: 'stop' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('shell');
});
it('times out as a 200, like the standalone wait', async () => {
const { app } = await harness();
const res = await send(app, { input: 'x', wait: 'blocked', waitTimeout: 1 });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.delivered).toBe(true);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.signal).toBeNull();
});
it('resolves with ended when the session goes away mid-wait', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.cancelAll(SESSION_ID);
expect((await pending).json().data.wait.ended).toBe(true);
});
it('a request-body close does NOT abort the wait', async () => {
// The regression this pins: on a POST the request stream closes as soon as the
// body has been read, well before the handler blocks. Treating that as a hang-up
// aborted every send-and-wait instantly. Hang-up handling itself is proven over
// real HTTP at the bottom of this file, because inject cannot produce a socket.
const { app, rawRequests } = await harness();
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 600_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
rawRequests[rawRequests.length - 1].emit('close');
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.signal).toBe('stop');
});
it('a duplicate skips the write but answers from current state instead of hanging', async () => {
// The original turn is long over, so requiring a fresh transition here would
// block a redelivery until timeout for no reason.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
await send(app, { input: 'first', clientId: 'c1', seq: 1 });
const before = session.writeBuffer.length;
const replay = await send(app, { input: 'first', clientId: 'c1', seq: 1, wait: 'idle' });
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.delivered).toBe(false);
expect(body.data.wait.immediate).toBe(true);
expect(body.data.wait.signal).toBe('idle');
expect(session.writeBuffer.length).toBe(before);
});
it('a duplicate on a BUSY session answers working, not idle', async () => {
// Previously unreachable: MockSession's status was 'working', which is not a
// SessionStatus, so signalForStatus fell through to null and this combination
// silently proved nothing.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
await send(app, { input: 'first', clientId: 'c2', seq: 1 });
session.status = 'busy';
const replay = await send(app, { input: 'first', clientId: 'c2', seq: 1, wait: 'working' });
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.wait.signal).toBe('working');
expect(body.data.wait.immediate).toBe(true);
expect(body.data.status).toBe('busy');
});
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const pendings = [];
for (let i = 0; i < 16; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
const rejected = await send(app, { input: 'x', clientId: 'c9', seq: 7, wait: true });
expect(rejected.statusCode).toBe(409);
expect(rejected.json().errorCode).toBe('SESSION_BUSY');
// Nothing written...
expect(session.writeBuffer.join('')).not.toContain('x');
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
// ...and the same seq is accepted on retry rather than treated as a replay.
const retry = await send(app, { input: 'x', clientId: 'c9', seq: 7 });
expect(retry.json()).toEqual({});
expect(session.writeBuffer.join('')).toContain('x');
});
it('a null wait is treated as absent, not as a validation error', async () => {
// Zod .optional() rejects null, and a third-party caller building the body with
// JSON.stringify keeps an explicit null on the wire.
const { app } = await harness();
const res = await send(app, { input: 'x', wait: null, waitTimeout: null });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
});
it('wait: false is treated as absent', async () => {
const { app } = await harness();
const res = await send(app, { input: 'x', wait: false });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('falls back to a direct write when the mux write fails, and still waits', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.writeViaMux = async () => false;
const pending = send(app, { input: 'fallback me', useMux: true, wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(session.writeBuffer.join('')).toContain('fallback me');
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('stop');
// The fallback write succeeded, so the input really was delivered.
expect(body.data.delivered).toBe(true);
});
});
describe('POST /api/sessions/:id/input: delivered reports the write, not just the dedup', () => {
it('reports delivered:false when BOTH write paths fail, instead of claiming delivery', async () => {
// A worker whose PTY has exited fails writeViaMux AND write. Reporting
// "delivered, but it timed out" points the agent at waiting longer; the truth is
// "restart the worker".
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.failWrites = true;
const res = await send(app, { input: 'run the tests', useMux: true, wait: 'stop', waitTimeout: 600_000 });
const body = res.json();
expect(res.statusCode).toBe(200);
expect(body.data.delivered).toBe(false);
expect(body.data.duplicate).toBe(false);
});
it('does not block for the full timeout on an input it knows never landed', async () => {
// The waiter has to be registered before the write, so it exists by the time the
// failure is known; releasing it immediately is what keeps the caller from
// waiting ten minutes for a turn that cannot start.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const started = Date.now();
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(Date.now() - started).toBeLessThan(2_000);
expect(body.data.delivered).toBe(false);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('reports delivered:false for a failed direct (non-mux) write too', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const body = (await send(app, { input: 'x', wait: 'stop', waitTimeout: 600_000 })).json();
expect(body.data.delivered).toBe(false);
});
it('rolls the dedup seq back when the write failed, so a retry is not a duplicate', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.failWrites = true;
const first = (await send(app, { input: 'x', clientId: 'c3', seq: 4, wait: 'stop', waitTimeout: 600_000 })).json();
expect(first.data.delivered).toBe(false);
session.failWrites = false;
const retry = (await send(app, { input: 'x', clientId: 'c3', seq: 4, wait: 'stop', waitTimeout: 1 })).json();
expect(retry.data.duplicate).toBe(false);
expect(retry.data.delivered).toBe(true);
expect(session.writeBuffer.join('')).toContain('x');
});
});
/**
* Client-hang-up handling, over REAL HTTP.
*
* `app.inject()` never emits a `close` event at all, so the entire abort path is
* invisible to every other test in this file — and the failure it hides is not
* subtle. On a POST, `req.raw` emits `'close'` as soon as the request BODY finishes
* streaming, which happens before the handler blocks (+1ms, `aborted: false`) and is
* indistinguishable from a real hang-up at +0ms. A request-side abort listener
* therefore cancels every send-and-wait instantly: `POST .../input {wait:"exit",
* waitTimeout:10000}` came back in 23ms with `ended:true, aborted:true, waitedMs:6`,
* i.e. the feature was dead while all 27 inject-based tests above stayed green.
*
* GET survives a request-side listener because it has no body to finish, which is
* exactly why this regression needs a POST and a real socket to catch.
*/
describe('POST /api/sessions/:id/input over real HTTP: hang-up handling', () => {
const PORT = 3181;
const base = `http://127.0.0.1:${PORT}`;
let app: FastifyInstance;
beforeAll(async () => {
app = (await harness()).app;
await app.listen({ port: PORT, host: '127.0.0.1' });
});
afterAll(async () => {
// fetch keeps its sockets alive, and `app.close()` waits for idle connections,
// so without this the teardown hook times out.
app.server.closeAllConnections();
await app.close();
});
it('a send-and-wait that is NOT aborted blocks for its full timeout', async () => {
// The regression: this returned in ~20ms with aborted:true.
const started = Date.now();
const res = await fetch(`${base}/api/sessions/${SESSION_ID}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'run the tests', wait: 'exit', waitTimeout: 2000 }),
});
const elapsed = Date.now() - started;
const body = await res.json();
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.waitedMs).toBeGreaterThan(1500);
expect(elapsed).toBeGreaterThan(1500);
expect(body.data.delivered).toBe(true);
});
it('a send-and-wait aborted mid-flight frees its waiter', async () => {
const controller = new AbortController();
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'x', wait: 'exit', waitTimeout: 600_000 }),
signal: controller.signal,
}).catch(() => 'aborted');
await new Promise((resolve) => setTimeout(resolve, 150));
// Still parked: the body finished streaming long ago, and that must not count.
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
controller.abort();
expect(await pending).toBe('aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('a GET wait behaves the same way on both counts', async () => {
const notAborted = await fetch(`${base}/api/sessions/${SESSION_ID}/wait?until=stop&timeout=1500`);
const body = await notAborted.json();
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.timedOut).toBe(true);
const controller = new AbortController();
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000`, {
signal: controller.signal,
}).catch(() => 'aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
controller.abort();
expect(await pending).toBe('aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('wait-output frees its waiter on hang-up too', async () => {
const controller = new AbortController();
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/wait-output?match=NEVER&timeout=600000`, {
signal: controller.signal,
}).catch(() => 'aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
controller.abort();
expect(await pending).toBe('aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
});
});
/**
* Send-and-wait against a tmux worker that has already died.
*
* `tmux send-keys` SUCCEEDS against a dead pane, so `writeViaMux` returns true and the
* old `delivered` was true for bytes written into a corpse — with `timedOut: true`
* alongside it, which tells an agent to wait longer when the truth is "restart the
* worker". Live: `pane_dead=1 status=42`, Codeman `pid=309406 status=idle`,
* `delivered: true`.
*/
describe('POST /api/sessions/:id/input: the pane is dead', () => {
function setPaneDead(ctx: MockRouteContext, dead: boolean) {
(ctx.mux as unknown as { isPaneDead: (n: string) => boolean }).isPaneDead = () => dead;
}
it('reports delivered:false even though the mux write "succeeded"', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
setPaneDead(ctx, true);
const res = await send(app, { input: 'run the tests', useMux: true, wait: 'stop', waitTimeout: 600_000 });
const body = res.json();
// The write itself did not fail — that is the whole trap.
expect(session.writeBuffer.join('')).toContain('run the tests');
expect(body.data.delivered).toBe(false);
expect(body.data.duplicate).toBe(false);
});
it('returns at once instead of blocking on a turn that cannot start', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, true);
const started = Date.now();
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(Date.now() - started).toBeLessThan(2_000);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('rolls the dedup seq back, so a retry against a restarted worker is not a duplicate', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
setPaneDead(ctx, true);
const dead = (await send(app, { input: 'x', useMux: true, clientId: 'c7', seq: 3, wait: 'stop' })).json();
expect(dead.data.delivered).toBe(false);
// Worker restarted.
setPaneDead(ctx, false);
_resetPaneLivenessState();
const retry = (
await send(app, { input: 'x', useMux: true, clientId: 'c7', seq: 3, wait: 'stop', waitTimeout: 1 })
).json();
expect(retry.data.duplicate).toBe(false);
expect(retry.data.delivered).toBe(true);
expect(session.writeBuffer.join('')).toContain('x');
});
it('a live pane still reports delivered:true', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
const pending = send(app, { input: 'x', useMux: true, wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
expect((await pending).json().data.delivered).toBe(true);
});
it('never probes tmux on the plain (non-wait) input path', async () => {
// The browser sends thousands of these per session; they must not exec tmux.
const { app, ctx } = await harness();
const probe = vi.fn(() => false);
(ctx.mux as unknown as { isPaneDead: (n: string) => boolean }).isPaneDead = probe as never;
await send(app, { input: 'hello', useMux: true });
await send(app, { input: 'hello again', useMux: true, clientId: 'c1', seq: 1 });
expect(probe).not.toHaveBeenCalled();
});
});
describe('POST /api/sessions/:id/input: `aborted` stays a client-side fact', () => {
it('reports aborted:false when the SERVER released the waiter after a failed delivery', async () => {
// api-reference guarantees a client never sees `aborted: true`, because it means
// "you hung up, nobody is reading this". The release below is the server giving up
// on a write that failed — and the client IS reading the response, so reporting
// `aborted: true` would both break that guarantee and hand an agent a second,
// contradictory reason for an outcome `delivered: false` already explains.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(body.data.delivered).toBe(false);
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
});
it('the same holds for a dead pane', async () => {
const { app, ctx } = await harness();
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => true;
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(body.data.wait.aborted).toBe(false);
});
});
describe('POST /api/sessions/:id/input: an oversized waitTimeout clamps', () => {
it('accepts a value above the old schema ceiling and reports the clamp', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 99_999_999_999 });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('still rejects a non-integer or negative waitTimeout', async () => {
const { app } = await harness();
for (const value of [-1, 0, 1.5]) {
const res = await send(app, { input: 'x', wait: 'stop', waitTimeout: value });
expect(res.statusCode, `waitTimeout=${value}`).toBe(400);
}
});
});
@@ -0,0 +1,432 @@
/**
* @fileoverview Route tests for `GET /api/sessions/:id/wait-output`.
*
* Same 200-on-timeout contract and same `data.wait` envelope as `/wait`. The
* additional things pinned here:
* - matching is LITERAL, and a `regex` parameter is rejected rather than ignored,
* so an agent that assumed herdr's `--regex` cannot silently wait on the wrong thing;
* - `from=buffer` scans what already scrolled past, bounded to a tail of the buffer,
* and is charged against the waiter cap BEFORE it materializes that buffer;
* - a chunk-straddling match still resolves, since PTY chunking is arbitrary;
* - a client that hangs up frees its waiter instead of holding it to the timeout.
*
* Plan: docs/agent-control-plan.md
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import type { ServerResponse } from 'node:http';
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
import { createSessionListeners, attachSessionListeners } from '../../src/web/session-listener-wiring.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_MATCH_LENGTH, MAX_BUFFER_SCAN_BYTES, MAX_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
const SESSION_ID = 'wait-output-session';
const URL = `/api/sessions/${SESSION_ID}/wait-output`;
afterEach(() => {
// Deliberately not `cancelEverything()`: it latches the registry's stopped flag,
// which would leave every later test in this file talking to a dead registry.
sessionWaits.cancelAll(SESSION_ID);
_resetPaneLivenessState();
});
/** Mirrors production's errorCode-to-status mapping; without it negative cases pass vacuously. */
async function harness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext; rawReplies: ServerResponse[] }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
const rawReplies: ServerResponse[] = [];
app.addHook('onRequest', async (req, reply) => {
// The RESPONSE, because that is what the handler's hang-up detection listens to.
rawReplies.push(reply.raw);
});
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, rawReplies };
}
describe('GET /api/sessions/:id/wait-output', () => {
it('resolves when the string appears on the stream', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifyOutput(SESSION_ID, 'running tests...\nBUILD OK\n');
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.wait.matched).toBe(true);
expect(body.data.wait.immediate).toBe(false);
expect(body.data.wait.snippet).toContain('BUILD OK');
expect(body.data.wait.match).toBe('BUILD OK');
expect(body.data.sessionId).toBe(SESSION_ID);
});
it('uses the same data.wait envelope as /wait, so one client helper reads both', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
const { data } = res.json();
expect(Object.keys(data).sort()).toEqual(['limitPaused', 'sessionId', 'status', 'wait']);
expect(data.matched).toBeUndefined();
expect(data.wait.matched).toBe(false);
expect(data.wait.aborted).toBe(false);
});
it('sends Cache-Control: no-store', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
expect(res.headers['cache-control']).toBe('no-store');
});
it('echoes the effective timeout after clamping', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'BUILD OK\n';
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&from=buffer&timeout=1800000` });
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('strips ANSI before matching, so colored output still matches', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, '\x1b[32mBUILD\x1b[0m OK\n');
expect((await pending).json().data.wait.matched).toBe(true);
});
it('matches across a chunk boundary', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, 'trailing text BUIL');
sessionWaits.notifyOutput(SESSION_ID, 'D OK done');
expect((await pending).json().data.wait.matched).toBe(true);
});
it('is case-sensitive by default and honors nocase=1', async () => {
const { app } = await harness();
const strict = app.inject({ method: 'GET', url: `${URL}?match=build%20ok&timeout=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, 'BUILD OK');
expect((await strict).json().data.wait.timedOut).toBe(true);
const loose = app.inject({ method: 'GET', url: `${URL}?match=build%20ok&nocase=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, 'BUILD OK');
const body = (await loose).json();
expect(body.data.wait.matched).toBe(true);
// Reported in the terminal's own casing, not the caller's.
expect(body.data.wait.snippet).toContain('BUILD OK');
});
it('from=buffer resolves immediately against output that already scrolled past', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'earlier output\n\x1b[32mBUILD OK\x1b[0m\n';
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&from=buffer` });
const body = res.json();
expect(body.data.wait.matched).toBe(true);
expect(body.data.wait.immediate).toBe(true);
expect(body.data.wait.waitedMs).toBe(0);
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('from=buffer only scans a bounded tail', async () => {
const { app, ctx } = await harness();
// Old marker pushed past the scan window by newer output.
ctx.sessions.get(SESSION_ID)!.terminalBuffer = `ANCIENT${'x'.repeat(MAX_BUFFER_SCAN_BYTES + 1000)}`;
const res = await app.inject({ method: 'GET', url: `${URL}?match=ANCIENT&from=buffer&timeout=1` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.timedOut).toBe(true);
});
it('defaults to from=now, ignoring what is already in the buffer', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'BUILD OK happened before you asked\n';
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&timeout=1` });
expect(res.json().data.wait.timedOut).toBe(true);
});
it('answers 200 with timedOut on timeout, never an error status', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.matched).toBe(false);
expect(body.data.wait.snippet).toBeNull();
});
it('resolves with ended when the session goes away mid-wait', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=never` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.cancelAll(SESSION_ID);
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.matched).toBe(false);
});
it('frees its waiter when the RESPONSE socket closes early', async () => {
// The injected response is genuinely destroyed by then, so the freed slot is what
// this can assert; the wire-level behaviour is pinned over real HTTP in
// session-input-wait.test.ts.
const { app, rawReplies } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=never&timeout=600000` }).then(
() => 'completed',
() => 'destroyed'
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
rawReplies[rawReplies.length - 1].emit('close');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
expect(await pending).toBe('destroyed');
});
it('rejects a regex parameter instead of silently ignoring it', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&regex=%5EBUILD.*OK%24` });
expect(res.statusCode).toBe(400);
const body = res.json();
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('match=');
});
it('requires match', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('rejects an empty or oversized match, and says which parameter was wrong', async () => {
const { app } = await harness();
const empty = await app.inject({ method: 'GET', url: `${URL}?match=` });
expect(empty.statusCode).toBe(400);
expect(empty.json().error).toContain('match');
const huge = await app.inject({ method: 'GET', url: `${URL}?match=${'x'.repeat(MAX_MATCH_LENGTH + 1)}` });
expect(huge.statusCode).toBe(400);
expect(huge.json().error).toContain('match');
});
it('rejects a non-numeric timeout', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&timeout=soon` });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(res.json().error).toContain('timeout');
});
it('rejects an unknown from value', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&from=history` });
expect(res.statusCode).toBe(400);
});
it('404s an unknown session', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/wait-output?match=x' });
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it('output waiters share the session waiter cap with signal waiters', async () => {
const { app } = await harness();
const pendings = [];
for (let i = 0; i < 8; i++) {
pendings.push(app.inject({ method: 'GET', url: `${URL}?match=never${i}` }));
}
for (let i = 0; i < 8; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
const overflow = await app.inject({ method: 'GET', url: `${URL}?match=one-too-many` });
expect(overflow.statusCode).toBe(409);
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
});
it('checks the cap BEFORE materializing the terminal buffer', async () => {
// `session.terminalBuffer` joins the whole 32MB accumulator. Paying that for a
// request that is about to be refused turns the cap into an amplifier: a caller
// already at the limit can loop `from=buffer` at full speed and never register a
// waiter, so nothing bounds the work.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const bufferReads = vi.fn(() => 'nothing to see');
Object.defineProperty(session, 'terminalBuffer', { get: bufferReads, configurable: true });
const pendings = [];
for (let i = 0; i < 16; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
const overflow = await app.inject({ method: 'GET', url: `${URL}?match=x&from=buffer` });
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
expect(bufferReads).not.toHaveBeenCalled();
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
});
});
/**
* The LIVE-STREAM half, wired the way production wires it.
*
* Everything above drives `sessionWaits.notifyOutput()` directly, which is the
* registry's API, not the path a real session takes. Deleting the one line that
* connects them — `sessionWaits.notifyOutput(session.id, data)` in the `terminal`
* listener — left all four wait suites green and survived a full `test:ci` sweep, so
* `wait-output?from=now` (the entire live mode, and the one the skill's recipes are
* built on) could ship severed with nothing to show for it.
*
* These go through `createSessionListeners` so the wiring itself is what is pinned.
*/
describe('GET /api/sessions/:id/wait-output: fed by the real terminal listener', () => {
function stubDeps() {
return {
broadcast: vi.fn(),
batchTerminalData: vi.fn(),
batchTaskUpdate: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
persistSessionState: vi.fn(),
getSessionStateWithRespawn: vi.fn(() => ({})),
getRunSummaryTracker: vi.fn(() => undefined),
stopTranscriptWatcher: vi.fn(),
cleanupSessionBatches: vi.fn(),
cancelPersistDebounce: vi.fn(),
removeRunSummaryTracker: vi.fn(),
removeSessionListenerRefs: vi.fn(),
cleanupRespawnOnExit: vi.fn(),
getStore: vi.fn(() => ({ updateRalphState: vi.fn() })),
registerAttachment: vi.fn(async () => {}),
};
}
it('matches output emitted by the session, not injected into the registry', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const deps = stubDeps();
attachSessionListeners(session as never, createSessionListeners(session as never, deps as never));
const pending = app.inject({ method: 'GET', url: `${URL}?match=LIVE_STREAM_HIT` });
await new Promise((resolve) => setTimeout(resolve, 20));
// What a PTY chunk actually does: the session emits `terminal`.
session.simulateTerminalOutput('$ echo LIVE_STREAM_HIT\r\nLIVE_STREAM_HIT\r\n');
const body = (await pending).json();
expect(body.data.wait.matched).toBe(true);
expect(body.data.wait.snippet).toContain('LIVE_STREAM_HIT');
// The listener must still forward to the SSE batcher; the wait feed is additive.
expect(deps.batchTerminalData).toHaveBeenCalled();
});
it('matches across chunk boundaries through the listener', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `${URL}?match=SPLIT_MARKER` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.simulateTerminalOutput('noise SPLIT_');
session.simulateTerminalOutput('MARKER more noise');
expect((await pending).json().data.wait.matched).toBe(true);
});
it('strips ANSI on the way through the listener', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `${URL}?match=COLORED%20HIT` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.simulateAnsiOutput('COLORED HIT');
expect((await pending).json().data.wait.matched).toBe(true);
});
});
describe('GET /api/sessions/:id/wait-output: a dead tmux worker', () => {
it('releases an output waiter when the worker dies while it is parked', async () => {
// The feed simply stops: no exit event, no further chunks, nothing to match.
const { app, ctx } = await harness();
let dead = false;
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
const pending = app.inject({ method: 'GET', url: `${URL}?match=NEVER&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
dead = true;
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
}, 10_000);
it('an oversized timeout clamps rather than 400ing', async () => {
const { app, ctx } = await harness();
// Resolve from the buffer so the assertion is about the clamp, not a real wait.
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'ALREADY_THERE\n';
const res = await app.inject({
method: 'GET',
url: `${URL}?match=ALREADY_THERE&from=buffer&timeout=99999999`,
});
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
});
+811
View File
@@ -0,0 +1,811 @@
/**
* @fileoverview Route tests for `GET /api/sessions/:id/wait`.
*
* The contract this pins is the one an orchestrating agent depends on:
* - a timeout is a 200 with `wait.timedOut: true`, never a 4xx, because callers loop
* over short waits and every poll boundary would otherwise look like a failure;
* - the result is nested under `data.wait` on ALL THREE wait endpoints, so a single
* client helper reads any of them;
* - the EFFECTIVE timeout is echoed, so a caller that asked for 30 minutes and was
* clamped to 10 can tell a poll boundary from a wedged worker;
* - an unknown `until` token is a 400 rather than a silent fallback to the default,
* so a typo can never leave an agent believing it is waiting for something else,
* and a schema 400 names the parameter it rejected;
* - `stop`/`blocked` are rejected for modes that install no hooks when asked for
* EXPLICITLY, but silently dropped from the DEFAULT set, so omitting `until` never
* 400s — and `shell` counts as such a mode, even though it is not an external CLI;
* - a client that hangs up frees its waiter immediately, or a loop of
* `curl --max-time` calls wedges a process-wide cap nobody else can use;
* - a session with no PTY answers `exit`, never `idle`.
*
* Plan: docs/agent-control-plan.md
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import type { ServerResponse } from 'node:http';
import {
registerSessionRoutes,
_resetPaneLivenessState,
_paneDeathWatcherCount,
} from '../../src/web/routes/session-routes.js';
import { createSessionListeners, attachSessionListeners } from '../../src/web/session-listener-wiring.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
const SESSION_ID = 'wait-routes-session';
/** Session ids a test parked filler waiters on, so cleanup can release them. */
const fillerIds = new Set<string>();
/** Park a waiter directly on the shared registry (cap tests), tracked for cleanup. */
function fillWaiter(id: string, owner?: string): Promise<unknown> {
fillerIds.add(id);
return sessionWaits.waitForSignal(id, { until: ['stop'], timeoutMs: 30_000, owner });
}
afterEach(() => {
// The routes use the process-wide registry; never leak a waiter into the next test.
// Deliberately NOT `cancelEverything()`: it latches the registry's stopped flag
// (one-way by design, so a request landing mid-shutdown cannot register a waiter
// nothing will ever cancel), which would leave every later test in this file
// talking to a dead registry and passing vacuously.
for (const id of [SESSION_ID, ...fillerIds]) sessionWaits.cancelAll(id);
fillerIds.clear();
// Pane-liveness state is module-level (one cache, one watcher per pane), so it has
// to be reset or a cached probe leaks into the next test.
_resetPaneLivenessState();
delete process.env.CODEMAN_MULTIUSER;
});
/**
* The shared route harness returns handler payloads verbatim, so a `{success:false}`
* body would still be HTTP 200. Production maps errorCode to status in server.ts, so
* mirror that here or every negative case passes vacuously.
*
* `rawReplies` collects each request's `reply.raw` — the RESPONSE, which is what the
* handler's hang-up detection listens on, and deliberately not `req.raw` (on a POST
* that one closes as soon as the body has been read, so wiring an abort to it kills
* every send-and-wait; see the real-HTTP suite in session-input-wait.test.ts).
* Emitting the event by hand is the only way to simulate a hang-up here at all:
* `app.inject()` never emits `close` on its own, verified.
*/
async function harness(options?: { authUser?: { username: string; role: 'admin' | 'user' } }): Promise<{
app: FastifyInstance;
ctx: MockRouteContext;
rawReplies: ServerResponse[];
}> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
const rawReplies: ServerResponse[] = [];
const authUser = options?.authUser;
app.addHook('onRequest', async (req, reply) => {
// The RESPONSE, because that is what the handler's hang-up detection listens to.
rawReplies.push(reply.raw);
if (authUser) (req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, rawReplies };
}
describe('GET /api/sessions/:id/wait', () => {
it('resolves immediately when the session is already in a requested state', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.wait.signal).toBe('idle');
expect(body.data.wait.immediate).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(body.data.wait.aborted).toBe(false);
expect(body.data.sessionId).toBe(SESSION_ID);
expect(body.data.wait.until).toEqual(['idle']);
});
it('nests the result under data.wait, so one client helper reads all three endpoints', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
const { data } = res.json();
// Session-level facts stay at the top; everything about the wait is inside it.
expect(Object.keys(data).sort()).toEqual(['limitPaused', 'sessionId', 'status', 'wait']);
expect(data.signal).toBeUndefined();
});
it('reports the post-wait status and the limit-pause hint', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
const body = res.json();
expect(body.data.status).toBe('idle');
expect(body.data.limitPaused).toBe(false);
});
it('sends Cache-Control: no-store, so a polled long-poll cannot be served from a cache', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.headers['cache-control']).toBe('no-store');
});
it('defaults to stop,idle,exit when until is omitted', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
const body = res.json();
expect(body.data.wait.until).toEqual(['stop', 'idle', 'exit']);
// The mock session is idle, so the default set resolves right away.
expect(body.data.wait.signal).toBe('idle');
});
it('rejects an unknown until token instead of falling back to the default', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stpo` });
expect(res.statusCode).toBe(400);
const body = res.json();
expect(body.success).toBe(false);
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('stpo');
});
it('rejects a non-numeric timeout', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=soon` });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('names the parameter it rejected, instead of a bare "invalid parameters"', async () => {
// An agent driving this with no docs in context can only recover if the error
// says WHICH parameter was wrong; the old message named none of them.
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=30s` });
const body = res.json();
expect(body.error).toContain('timeout');
expect(body.error).toContain('wait');
});
it('404s an unknown session', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/wait?until=idle' });
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it('resolves an in-flight wait when the signal arrives', async () => {
const { app } = await harness();
// `stop` is not the session's current signal, so this blocks.
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('stop');
expect(body.data.wait.immediate).toBe(false);
expect(body.data.wait.timedOut).toBe(false);
expect(body.data.wait.waitedMs).toBeGreaterThanOrEqual(0);
});
it('fresh=1 waits for the next transition instead of answering from current state', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&fresh=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'idle');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('idle');
expect(body.data.wait.immediate).toBe(false);
});
it('resolves with ended when the session goes away mid-wait', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.cancelAll(SESSION_ID);
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.signal).toBeNull();
expect(body.data.wait.timedOut).toBe(false);
});
it('answers 200 with timedOut on timeout, never an error status', async () => {
const { app } = await harness();
// Clamped up to the 1s floor, so this is the one deliberately slow case.
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=1` });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.signal).toBeNull();
expect(body.data.wait.ended).toBe(false);
});
});
describe('GET /api/sessions/:id/wait: the effective timeout is observable', () => {
it('echoes the clamped-down value when the caller asks for more than the ceiling', async () => {
// Asked for 30 minutes, got MAX_WAIT_MS. Without the echo the caller reads a
// 10-minute timeout as "30 minutes elapsed with no stop" and kills a healthy worker.
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1800000` });
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('echoes the clamped-up value at the floor too', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
expect(res.json().data.wait.timeoutMs).toBe(MIN_WAIT_MS);
});
it('reports the applied default when timeout is omitted', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.json().data.wait.timeoutMs).toBeGreaterThanOrEqual(MIN_WAIT_MS);
expect(res.json().data.wait.timeoutMs).toBeLessThanOrEqual(MAX_WAIT_MS);
});
});
describe('GET /api/sessions/:id/wait: repeated query parameters', () => {
it('accepts ?until=stop&until=exit, the way most clients express a list', async () => {
// Fastify delivers a repeated parameter as an array and parseWaitSignals has
// always handled one; only the schema was rejecting it.
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&until=exit` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'exit');
const body = (await pending).json();
expect(body.data.wait.until).toEqual(['stop', 'exit']);
expect(body.data.wait.signal).toBe('exit');
});
it('still reports an unknown token inside a repeated parameter', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&until=stpo` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('stpo');
});
});
describe('GET /api/sessions/:id/wait: a client that hangs up frees its waiter', () => {
it('removes the waiter when the RESPONSE socket closes early', async () => {
// `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter every
// iteration of the documented loop; sixteen of those and an innocent session
// reports busy.
//
// The response body is unreadable afterwards (the injected response really is
// destroyed, exactly as a hung-up socket would be), so the freed slot is all this
// can assert. The full behaviour, including `aborted: true` on the wire for the
// caller that did NOT hang up, is pinned over real HTTP in session-input-wait.test.ts.
const { app, rawReplies } = await harness();
const pending = app
.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` })
.then(
() => 'completed',
() => 'destroyed'
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
rawReplies[rawReplies.length - 1].emit('close');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
expect(await pending).toBe('destroyed');
});
it('a close AFTER the wait resolved changes nothing', async () => {
const { app, rawReplies } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.json().data.wait.aborted).toBe(false);
// Node fires `close` on every completed response too, not only on a hang-up;
// `writableFinished` is what separates them.
rawReplies[rawReplies.length - 1].emit('close');
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
});
describe('GET /api/sessions/:id/wait: capacity errors name the cap that was hit', () => {
it('maps the per-session cap to SESSION_BUSY / 409', async () => {
const { app } = await harness();
const pendings = [];
for (let i = 0; i < 16; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 30));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(16);
const overflow = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(overflow.statusCode).toBe(409);
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
expect(overflow.json().error).toContain('session');
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
});
it('maps the process-wide cap to RATE_LIMITED / 429, because this session is not the problem', async () => {
// Reported as SESSION_BUSY, an agent concludes the session it asked about is
// busy, switches to another, and gets the identical error.
const { app } = await harness();
const others: Promise<unknown>[] = [];
for (let i = 0; i < MAX_WAITERS_TOTAL; i++) others.push(fillWaiter(`unrelated-${i}`));
expect(sessionWaits.totalWaiterCount()).toBe(MAX_WAITERS_TOTAL);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(429);
expect(res.json().errorCode).toBe('RATE_LIMITED');
expect(res.json().error).toContain('total');
for (const id of fillerIds) sessionWaits.cancelAll(id);
await Promise.all(others);
});
it('maps the per-owner cap to RATE_LIMITED / 429 and charges the request to its user', async () => {
// Also proves the route passes ownerFor(req): without it the owner cap can never
// trip, and one user could hold the whole process-wide pool.
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ authUser: { username: 'alice', role: 'admin' } });
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.ownerWaiterCount('alice')).toBe(1);
const others: Promise<unknown>[] = [];
while (sessionWaits.ownerWaiterCount('alice') < MAX_WAITERS_PER_OWNER) {
others.push(fillWaiter(`alice-${others.length}`, 'alice'));
}
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(429);
expect(res.json().error).toContain('owner');
sessionWaits.cancelAll(SESSION_ID);
for (const id of fillerIds) sessionWaits.cancelAll(id);
await Promise.all([pending, ...others]);
});
});
describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
it('rejects an explicit stop, which no external CLI ever emits', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('codex');
});
it('rejects an explicit blocked too', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'opencode';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked` });
expect(res.statusCode).toBe(400);
});
it('rejects stop for a SHELL session, which is not an external CLI but installs no hooks either', async () => {
// A plain bash PTY never POSTs a Stop hook, so this was a guaranteed ten-minute
// hang dressed up as a timeout — the exact failure the guard exists to prevent.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('shell');
});
it('silently drops hook-only signals from the DEFAULT set instead of 400ing', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'gemini';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
});
it('drops them from the default set for shell too', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
});
it('still accepts idle and exit explicitly', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'antigravity';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,exit` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
});
});
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
// Session parks a dead PTY at status 'idle' and the object survives in the map,
// so the DEFAULT wait used to answer {signal:"idle", immediate:true} for a
// crashed worker — 200, success, no error, and the agent prompts a corpse.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.pid = null;
session.status = 'idle';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
const body = res.json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.immediate).toBe(true);
// The raw status is still reported, so nothing is hidden from the caller.
expect(body.data.status).toBe('idle');
});
it('resolves until=exit immediately for an already-exited session instead of blocking', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.pid = null;
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&timeout=1` });
expect(res.json().data.wait.signal).toBe('exit');
expect(res.json().data.wait.timedOut).toBe(false);
});
it('does not report idle for a dead session even when idle was asked for explicitly', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.pid = null;
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
expect(res.json().data.wait.signal).toBeNull();
expect(res.json().data.wait.timedOut).toBe(true);
});
it('a live busy session resolves until=working immediately', async () => {
// Unreachable before: MockSession used 'working', which is not a SessionStatus,
// so signalForStatus fell through to null and this branch had no coverage.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.status = 'busy';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=working` });
expect(res.json().data.wait.signal).toBe('working');
expect(res.json().data.wait.immediate).toBe(true);
});
it('a live stopped/error session maps to exit', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.status = 'stopped';
const stopped = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit` });
expect(stopped.json().data.wait.signal).toBe('exit');
session.status = 'error';
const errored = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit` });
expect(errored.json().data.wait.signal).toBe('exit');
});
});
/**
* The other half of the wait contract, exercised through the real listener wiring
* rather than a route: a PTY that dies must RELEASE every waiter, not only the ones
* that asked for `exit`.
*
* It lives in this file because it pins the same promise the routes above make
* ("never hang"), and because the failure is only visible from the caller's side:
* the exit handler detaches the `terminal`, `idle` and `working` listeners moments
* later, so anything still registered afterwards is waiting on feeds that no longer
* exist and can only time out.
*/
describe('a PTY exit releases waiters that did not ask for exit', () => {
/** Everything the exit handler touches; the wait release must not depend on any of it. */
function stubDeps(overrides: Record<string, unknown> = {}) {
return {
broadcast: vi.fn(),
batchTerminalData: vi.fn(),
batchTaskUpdate: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
persistSessionState: vi.fn(),
getSessionStateWithRespawn: vi.fn(() => ({})),
getRunSummaryTracker: vi.fn(() => undefined),
stopTranscriptWatcher: vi.fn(),
cleanupSessionBatches: vi.fn(),
cancelPersistDebounce: vi.fn(),
removeRunSummaryTracker: vi.fn(),
removeSessionListenerRefs: vi.fn(),
cleanupRespawnOnExit: vi.fn(),
getStore: vi.fn(() => ({ updateRalphState: vi.fn() })),
registerAttachment: vi.fn(async () => {}),
...overrides,
};
}
it('answers an until=working waiter with ended instead of leaving it to time out', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=working&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
session.emit('exit', 1);
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(body.data.wait.signal).toBeNull();
});
it('still gives an until=exit waiter its signal, not a bare ended', async () => {
// Ordering matters: notifySignal('exit') must run BEFORE cancelAll, or a caller
// that asked the right question gets the generic answer.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&fresh=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.emit('exit', 0);
const body = (await pending).json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.ended).toBe(false);
});
it('releases output waiters too, whose only feed the exit handler is about to detach', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({
method: 'GET',
url: `/api/sessions/${SESSION_ID}/wait-output?match=DONE&timeout=600000`,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
session.emit('exit', 1);
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.matched).toBe(false);
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(0);
});
it('releases them even when a later step of the exit handler throws', async () => {
// Which is why the release is the first thing in the handler.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const deps = stubDeps({
broadcast: vi.fn(() => {
throw new Error('SSE is down');
}),
});
attachSessionListeners(session as never, createSessionListeners(session as never, deps as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.emit('exit', 1);
expect((await pending).json().data.wait.ended).toBe(true);
});
});
/**
* Worker liveness for a tmux-backed session.
*
* `session.pid` is the local `tmux attach` CLIENT, not the worker. Codeman sets
* `remain-on-exit on`, so when the command inside the pane exits tmux keeps the pane
* (`pane_dead=1`), the tmux session survives, the attach client keeps running, `pid`
* never goes null and NO exit event fires. Reproduced live on a shell worker killed
* with `exit 42`: tmux said `pane_dead=1 status=42` while Codeman said
* `pid=309406 status=idle` and the default wait answered
* `{signal:"idle", immediate:true, waitedMs:0}` for a corpse.
*
* These cases could not exist before, because `MockSession.pid` is set by hand: the
* `pid === null` branch is the one production never reaches.
*/
describe('GET /api/sessions/:id/wait: a dead tmux worker', () => {
/** Mock ctx doubles carry no `isPaneDead`; the route treats that as "cannot tell". */
function setPaneDead(ctx: MockRouteContext, dead: boolean): ReturnType<typeof vi.fn> {
const probe = vi.fn(() => dead);
(ctx.mux as unknown as { isPaneDead: (name: string) => boolean }).isPaneDead = probe as never;
return probe;
}
it('answers exit, not the idle the session still reports', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
setPaneDead(ctx, true);
// Exactly the live state: attach client alive, status idle, worker gone.
expect(session.pid).not.toBeNull();
expect(session.status).toBe('idle');
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
const body = res.json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.immediate).toBe(true);
// The raw status is still reported, so nothing is hidden from the caller.
expect(body.data.status).toBe('idle');
});
it('resolves until=exit immediately instead of burning the whole timeout', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, true);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&timeout=1` });
expect(res.json().data.wait.signal).toBe('exit');
expect(res.json().data.wait.timedOut).toBe(false);
});
it('does not answer idle for a dead worker even when idle was asked for explicitly', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, true);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
expect(res.json().data.wait.signal).toBeNull();
expect(res.json().data.wait.timedOut).toBe(true);
});
it('a live pane is unaffected', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.json().data.wait.signal).toBe('idle');
});
it('caches the probe, so a poll loop cannot exec tmux once per request', async () => {
const { app, ctx } = await harness();
const probe = setPaneDead(ctx, false);
for (let i = 0; i < 10; i++) {
await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
}
expect(probe.mock.calls.length).toBeLessThanOrEqual(2);
});
it('never probes a session that is not tmux-backed', async () => {
const { app, ctx } = await harness();
const probe = setPaneDead(ctx, true);
ctx.sessions.get(SESSION_ID)!.usesMux = false;
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(probe).not.toHaveBeenCalled();
// Falls back to the pid rule, which is the right one for a direct PTY.
expect(res.json().data.wait.signal).toBe('idle');
});
it('releases a wait when the worker dies WHILE it is parked', async () => {
// The common orchestration case, and the one a request-time probe cannot see: no
// exit event, no output, nothing — the caller would block for its full timeout.
const { app, ctx } = await harness();
let dead = false;
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
expect(_paneDeathWatcherCount()).toBe(1);
dead = true;
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
// ...and the watcher is torn down with the last waiter that needed it.
expect(_paneDeathWatcherCount()).toBe(0);
}, 10_000);
it('an until=exit caller parked when the worker dies gets its signal, not a bare ended', async () => {
const { app, ctx } = await harness();
let dead = false;
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
const pending = app.inject({
method: 'GET',
url: `/api/sessions/${SESSION_ID}/wait?until=exit&fresh=1&timeout=600000`,
});
await new Promise((resolve) => setTimeout(resolve, 50));
dead = true;
const body = (await pending).json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.ended).toBe(false);
}, 10_000);
it('starts no watcher at all when the session is not tmux-backed', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
ctx.sessions.get(SESSION_ID)!.usesMux = false;
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 30));
expect(_paneDeathWatcherCount()).toBe(0);
sessionWaits.cancelAll(SESSION_ID);
await pending;
});
it('shares ONE watcher across every wait parked on the same session', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
const pendings = [];
for (let i = 0; i < 5; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(5);
expect(_paneDeathWatcherCount()).toBe(1);
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
expect(_paneDeathWatcherCount()).toBe(0);
});
});
describe('GET /api/sessions/:id/wait: an oversized timeout clamps, it does not 400', () => {
it('accepts a value above the old schema ceiling and reports the clamp', async () => {
// "Clamped to [1000, 600000]" has to mean it: `timeout=600001` clamping while
// `timeout=99999999` 400s is the same documented rule producing two outcomes.
const { app } = await harness();
const res = await app.inject({
method: 'GET',
url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=99999999`,
});
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('still rejects a non-finite or non-integer timeout', async () => {
const { app } = await harness();
for (const value of ['1e999', 'soon', '-1', '1.5']) {
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=${value}` });
expect(res.statusCode, `timeout=${value}`).toBe(400);
}
});
});
+35
View File
@@ -64,3 +64,38 @@ describe('buildMuxAttachEnv', () => {
}
});
});
describe('spawn env CODEMAN_API_URL (no fallback)', () => {
const withApiUrl = (value: string | undefined, fn: () => void) => {
const original = process.env.CODEMAN_API_URL;
if (value === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = value;
try {
fn();
} finally {
if (original === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = original;
}
};
it('passes the server-stamped URL through verbatim', async () => {
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
withApiUrl('https://127.0.0.1:3199', () => {
expect(buildClaudeEnv('test-session').CODEMAN_API_URL).toBe('https://127.0.0.1:3199');
expect(buildShellEnv('test-session').CODEMAN_API_URL).toBe('https://127.0.0.1:3199');
});
});
// A hardcoded fallback was the wrong scheme on HTTPS installs. The key must be
// genuinely ABSENT when unset: present-with-undefined would serialize through
// node-pty as the literal string "CODEMAN_API_URL=undefined" (COD-115).
it('leaves the key absent (not undefined, not a fallback) when the server has not stamped one', async () => {
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
withApiUrl(undefined, () => {
for (const env of [buildClaudeEnv('test-session'), buildShellEnv('test-session')]) {
expect('CODEMAN_API_URL' in env).toBe(false);
expect(JSON.stringify(env)).not.toContain('localhost:3000');
}
});
});
});
File diff suppressed because it is too large Load Diff
+31 -4
View File
@@ -247,14 +247,41 @@ describe('TmuxManager (unit)', () => {
});
describe('environment exports', () => {
it('keeps COLORTERM unset for OpenCode sessions', () => {
const exports = (
const callBuildEnvExports = (mode: string) =>
(
manager as unknown as {
buildEnvExports(sessionId: string, muxName: string, mode: string): string[];
}
).buildEnvExports('session-1', 'codeman-abc12345', 'opencode');
).buildEnvExports('session-1', 'codeman-abc12345', mode);
expect(exports).toContain('unset COLORTERM');
it('keeps COLORTERM unset for OpenCode sessions', () => {
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
});
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
const original = process.env.CODEMAN_API_URL;
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';
try {
expect(callBuildEnvExports('claude')).toContain('export CODEMAN_API_URL=https://127.0.0.1:3199');
} finally {
if (original === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = original;
}
});
// A hardcoded fallback exported the wrong scheme on HTTPS installs; unset must
// stay unset so in-session guards fail closed instead of curling a bad URL.
it('exports no CODEMAN_API_URL at all when the server has not stamped one', () => {
const original = process.env.CODEMAN_API_URL;
delete process.env.CODEMAN_API_URL;
try {
const exports = callBuildEnvExports('claude');
expect(exports.some((line) => line.startsWith('export CODEMAN_API_URL'))).toBe(false);
expect(exports.join(' ')).not.toContain('localhost:3000');
} finally {
if (original === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = original;
}
});
});