agenthub/tests/sse.test.ts
chahinebrini 6d088648da feat(hub): Korrektheits- und Realtime-Härtung — Alias, Lifecycle, Presence, Architekten-Pulse (TSK-0242/0245/0249)
Drei vom Architekten abgenommene Tasks, gebündelt als Checkpoint:

- TSK-0242: Agent-Alias-Mapping (kimi-ah → kimi kanonisiert, Rollen → preferredAgent),
  reopenTask räumt claimedBy ab, fsWatch reindiziert direkte Datei-Edits,
  work-Default 300s → 50s, task_list mit Limit.
- TSK-0245: zwei Agent-Klassen (dispatch loop|architect). Watchdog mahnt
  architekt-getriebene Agenten nur noch EINMAL statt im Minutentakt;
  `task dispatch` startet sie explizit, `task record` trägt extern
  erledigte Arbeit mit origin=external nach.
- TSK-0249: Lifecycle wird serverseitig erzwungen (open→review scheitert mit
  klarer Meldung), claimedBy/doneBy überleben bis done, Presence pro Agent,
  Review-Watchdog, GET /architect/pulse (1.4 kB statt 34 kB, since-Cursor,
  omitted statt stillem Abschneiden), unbekannter Agent → 400 statt 500.

Alle Punkte live am laufenden Hub nachgemessen, nicht aus Agenten-Logs übernommen.
Tests: 242 → 279 grün, tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:48:24 +02:00

792 lines
31 KiB
TypeScript

/**
* Tests for the SSE realtime channel (TSK-0006 part 1).
*
* Three concerns:
* 1. Unit: parseSSEBuffer + formatEvent (pure functions, no I/O).
* 2. Integration: eventBus emits on REST mutations (via app.inject).
* 3. E2E: a real server delivers a task/created event over the SSE stream.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { startServer } from '../src/server/index.js';
import { eventBus } from '../src/server/events.js';
import type { AgentHubEvent } from '../src/server/events.js';
import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js';
import { init } from '../src/cli/commands/init.js';
import { getFsWatchErrors, resetFsWatchErrors, startEntityWatcher } from '../src/server/fsWatch.js';
import { createTask, claimTask, listTasks } from '../src/core/services/taskService.js';
import { listHubEventsAfter } from '../src/server/eventLog.js';
// ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
describe('parseSSEBuffer', () => {
it('parses a single complete event', () => {
const buf = 'data: {"type":"task","action":"created","id":"TSK-0001"}\n\n';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0].type).toBe('task');
expect(events[0].action).toBe('created');
expect(events[0].id).toBe('TSK-0001');
expect(remaining).toBe('');
});
it('parses multiple complete events in one buffer', () => {
const buf =
'data: {"type":"task","action":"created","id":"TSK-0001"}\n\n' +
'data: {"type":"memory","action":"created","id":"MEM-001"}\n\n';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(2);
expect(events[0].type).toBe('task');
expect(events[1].type).toBe('memory');
expect(remaining).toBe('');
});
it('ignores SSE keepalive comment blocks', () => {
const buf =
':\n\n' +
'data: {"type":"handoff","action":"created","id":"HOF-0001"}\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0].id).toBe('HOF-0001');
});
it('keeps an incomplete tail in `remaining`', () => {
const buf =
'data: {"type":"task","action":"created","id":"TSK-0001"}\n\n' +
'data: {"type":"deci'; // truncated chunk
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(remaining).toBe('data: {"type":"deci');
});
it('returns empty events and the full buffer when there is no complete event', () => {
const buf = 'data: {"type":"task"'; // no \n\n yet
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(0);
expect(remaining).toBe('data: {"type":"task"');
});
it('silently skips malformed JSON without throwing', () => {
const buf = 'data: NOT_JSON\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(0);
});
it('attaches the SSE id field as event.seq', () => {
const buf = 'id: 42\ndata: {"type":"task","action":"created","id":"TSK-0042"}\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ id: 'TSK-0042', seq: 42 });
});
it('ignores named task-log events in the generic change parser', () => {
const buf = 'event: task-log\ndata: {"taskId":"TSK-0001","text":"progress"}\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(0);
});
});
// ─── 2. Unit: formatEvent ────────────────────────────────────────────────────
describe('formatEvent (AgentHub-branded)', () => {
it('every line starts with the AgentHub: prefix', () => {
const ev: AgentHubEvent = { type: 'decision', action: 'created', id: 'DEC-0001' };
expect(formatEvent(ev).startsWith('AgentHub: ')).toBe(true);
});
it('formats a created task as "Task received <id> <title> [status]"', () => {
const ev: AgentHubEvent = { type: 'task', action: 'created', id: 'TSK-0019', title: 'Magic-App Audit', status: 'open' };
expect(formatEvent(ev)).toMatch(/^AgentHub: Task received\s+TSK-0019\s+Magic-App Audit\s+\[open\]$/);
});
it('formats a created task with no title as "Task received <id> [status]"', () => {
const ev: AgentHubEvent = { type: 'task', action: 'created', id: 'TSK-0001', status: 'open' };
expect(formatEvent(ev)).toMatch(/^AgentHub: Task received\s+TSK-0001\s+\[open\]$/);
});
it('formats a completed task as "Task done <id> by <agent>"', () => {
const ev: AgentHubEvent = {
type: 'task',
action: 'updated',
id: 'TSK-0003',
title: 'Implement auth',
status: 'done',
role: 'implementer',
assignedTo: 'windows-claude',
};
expect(formatEvent(ev)).toMatch(/^AgentHub: Task done\s+TSK-0003\s+by windows-claude$/);
});
it('formats a claimed task as "Task claimed <id> by <agent>"', () => {
const ev: AgentHubEvent = { type: 'task', action: 'updated', id: 'TSK-0014', status: 'in_progress', assignedTo: 'codex' };
expect(formatEvent(ev)).toMatch(/^AgentHub: Task claimed\s+TSK-0014\s+by codex$/);
});
it('formats a handoff as "Handoff <id> → <role>"', () => {
const ev: AgentHubEvent = { type: 'handoff', action: 'created', id: 'HOF-0012', title: 'For codex', role: 'implementer' };
expect(formatEvent(ev)).toMatch(/^AgentHub: Handoff\s+HOF-0012\s+.*→ implementer$/);
});
it('formats a decision with no detail as just the prefix + id', () => {
const ev: AgentHubEvent = { type: 'decision', action: 'created', id: 'DEC-0001' };
expect(formatEvent(ev)).toMatch(/^AgentHub: Decision\s+DEC-0001$/);
});
it('formats an agent presence event as "<agent> joined (<role>)"', () => {
const ev: AgentHubEvent = { type: 'agent', action: 'joined', id: 'codex', role: 'implementer' };
expect(formatEvent(ev)).toBe('AgentHub: codex joined (implementer)');
});
it('formats an agent join with no role', () => {
const ev: AgentHubEvent = { type: 'agent', action: 'joined', id: 'kimi' };
expect(formatEvent(ev)).toBe('AgentHub: kimi joined');
});
it('formats a review submission as "Task review <id> by <agent>"', () => {
const ev: AgentHubEvent = { type: 'task', action: 'updated', id: 'TSK-0019', status: 'review', assignedTo: 'codex' };
expect(formatEvent(ev)).toMatch(/^AgentHub: Task review\s+TSK-0019\s+by codex$/);
});
it('formats a reopened task as "Task reopened <id>"', () => {
const ev: AgentHubEvent = { type: 'task', action: 'updated', id: 'TSK-0019', status: 'open' };
expect(formatEvent(ev)).toMatch(/^AgentHub: Task reopened\s+TSK-0019$/);
});
});
// ─── 3. Integration: eventBus fires on REST mutations ────────────────────────
describe('eventBus mutations', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
const collected: AgentHubEvent[] = [];
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-sse-bus-'));
init(cwd, { projectName: 'sse-bus-test', yes: true });
app = buildApp(cwd);
eventBus.on('change', (e: AgentHubEvent) => collected.push(e));
});
afterEach(async () => {
eventBus.removeAllListeners('change');
collected.length = 0;
await app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('emits task/created when POST /tasks succeeds', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Bus task', role: 'implementer' } });
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'created', id: 'TSK-0001', title: 'Bus task', role: 'implementer' });
expect(collected[0].seq).toBe(1);
});
it('emits task/updated when PATCH /tasks/:id changes status', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' });
expect(collected[0].seq).toBe(4);
});
it('emits task/updated with assignedTo when a task is claimed', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'mac-claude' } });
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'mac-claude' });
});
it('emits task/updated review when an implementer submits for review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', status: 'review' });
});
it('emits task/updated open when the architect reopens after review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', status: 'open' });
});
it('emits handoff/created when POST /handoffs succeeds', async () => {
await app.inject({
method: 'POST',
url: '/handoffs',
payload: { fromRole: 'architect', toRole: 'implementer', summary: 'Design complete', context: 'See decisions' },
});
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'handoff', action: 'created' });
});
it('emits decision/created when POST /decisions succeeds', async () => {
await app.inject({
method: 'POST',
url: '/decisions',
payload: { title: 'Use SQLite', decision: 'SQLite for local storage', context: 'Low overhead' },
});
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'decision', action: 'created', title: 'Use SQLite' });
});
it('emits memory/created when POST /memory succeeds', async () => {
await app.inject({
method: 'POST',
url: '/memory',
payload: { title: 'DNS cache fact', category: 'technical', content: 'TTL matters' },
});
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'memory', action: 'created', title: 'DNS cache fact' });
});
it('does NOT emit when task creation fails (400)', async () => {
// title: '' fails TaskSchema min(1) — createTask throws, route returns 400.
const res = await app.inject({ method: 'POST', url: '/tasks', payload: { title: '' } });
expect(res.statusCode).toBe(400);
// No event should be emitted on failure.
expect(collected).toHaveLength(0);
});
});
// ─── 4. E2E: SSE stream delivers events over a real HTTP connection ───────────
describe('SSE stream e2e', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-sse-e2e-'));
init(cwd, { projectName: 'sse-e2e-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('sends an immediate keepalive comment when /events connects', async () => {
const controller = new AbortController();
const res = await fetch(`${server.url}/events`, { signal: controller.signal });
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.body).toBeTruthy();
const reader = res.body!.getReader();
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('SSE: no keepalive frame within 1 s')), 1000);
});
const chunk = await Promise.race([reader.read(), timeout]);
controller.abort();
expect(chunk.done).toBe(false);
expect(new TextDecoder().decode(chunk.value)).toContain(': connected\n\n');
}, 3000);
it('delivers task/created to a connected SSE subscriber', async () => {
const controller = new AbortController();
// Start listening BEFORE posting the task.
const firstEventPromise = new Promise<AgentHubEvent>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('SSE: no event received within 3 s')),
3000,
);
fetch(`${server.url}/events`, { signal: controller.signal })
.then(async (res) => {
if (!res.body) {
clearTimeout(timeout);
reject(new Error('SSE response has no body'));
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
let done: boolean;
let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch {
break; // AbortError when controller.abort() is called
}
if (done) break;
if (value) buf += dec.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buf);
buf = remaining;
for (const ev of events) {
clearTimeout(timeout);
resolve(ev);
return;
}
}
})
.catch((err: unknown) => {
// AbortError is expected when we abort after receiving the event.
if (err instanceof Error && err.name === 'AbortError') return;
reject(err);
});
});
// Give the SSE connection a moment to be established before posting.
await new Promise((r) => setTimeout(r, 80));
// Trigger a mutation through the REST API.
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'SSE e2e task', role: 'implementer' }),
});
const event = await firstEventPromise;
controller.abort(); // stop the SSE listener
expect(event.type).toBe('task');
expect(event.action).toBe('created');
expect(event.id).toBe('TSK-0001');
expect(event.title).toBe('SSE e2e task');
expect(event.role).toBe('implementer');
}, 5000);
it('delivers an agent/joined presence event from POST /announce', async () => {
const controller = new AbortController();
const firstEventPromise = new Promise<AgentHubEvent>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('SSE: no announce event within 3 s')), 3000);
fetch(`${server.url}/events`, { signal: controller.signal })
.then(async (res) => {
if (!res.body) { clearTimeout(timeout); reject(new Error('no body')); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
let done: boolean; let value: Uint8Array | undefined;
try { ({ done, value } = await reader.read()); } catch { break; }
if (done) break;
if (value) buf += dec.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buf);
buf = remaining;
for (const ev of events) { clearTimeout(timeout); resolve(ev); return; }
}
})
.catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') return;
reject(err);
});
});
await new Promise((r) => setTimeout(r, 80));
await fetch(`${server.url}/announce`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent: 'codex', role: 'implementer' }),
});
const event = await firstEventPromise;
controller.abort();
expect(event.type).toBe('agent');
expect(event.action).toBe('joined');
expect(event.id).toBe('codex');
expect(event.role).toBe('implementer');
}, 5000);
it('role filter on /events?role= suppresses non-matching tasks', async () => {
const controller = new AbortController();
const received: AgentHubEvent[] = [];
// Subscribe with role=architect — should NOT see the implementer task.
const listenDone = fetch(`${server.url}/events?role=architect`, { signal: controller.signal })
.then(async (res) => {
if (!res.body) return;
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
let done: boolean;
let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch {
break;
}
if (done) break;
if (value) buf += dec.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buf);
buf = remaining;
received.push(...events);
}
})
.catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') return;
throw err;
});
await new Promise((r) => setTimeout(r, 80));
// Post a task for 'implementer' role — should be filtered out.
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Implementer task', role: 'implementer' }),
});
// Give time for any event to arrive (it shouldn't).
await new Promise((r) => setTimeout(r, 150));
controller.abort();
await listenDone;
expect(received).toHaveLength(0);
}, 5000);
});
describe('SSE durable replay', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-sse-replay-'));
init(cwd, { projectName: 'sse-replay-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('persists monotonic ids and replays missed events after Last-Event-ID', async () => {
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'first', role: 'implementer' }),
});
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'second', role: 'implementer' }),
});
expect(listHubEventsAfter(cwd, 0).map((e) => e.seq)).toEqual([1, 2]);
const controller = new AbortController();
const res = await fetch(`${server.url}/events`, {
signal: controller.signal,
headers: { Accept: 'text/event-stream', 'Last-Event-ID': '1' },
});
expect(res.ok).toBe(true);
expect(res.body).toBeTruthy();
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const parsed = parseSSEBuffer(buffer);
buffer = parsed.remaining;
if (parsed.events.length) {
await reader.cancel();
controller.abort();
expect(parsed.events).toHaveLength(1);
expect(parsed.events[0]).toMatchObject({ seq: 2, id: 'TSK-0002', title: 'second' });
return;
}
}
await reader.cancel().catch(() => undefined);
controller.abort();
throw new Error('Timed out waiting for replayed event');
}, 5000);
});
// ─── 5. fsWatch: CLI / direct file writes also emit (TSK-0006 part 2) ─────────
// The whole point of the watcher: a `agenthub task create` (no --server) writes
// the entity file directly, bypassing the REST emit path — yet a connected
// watcher must still be triggered. Here we drive the services directly (exactly
// what the CLI does) and assert the eventBus fires.
async function waitFor(
collected: AgentHubEvent[],
pred: (e: AgentHubEvent) => boolean,
ms = 2000,
): Promise<AgentHubEvent | undefined> {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
const hit = collected.find(pred);
if (hit) return hit;
await new Promise((r) => setTimeout(r, 25));
}
return collected.find(pred);
}
describe('fsWatch emits for non-REST (CLI/file) writes', () => {
let cwd: string;
let stop: () => void;
const collected: AgentHubEvent[] = [];
const onChange = (e: AgentHubEvent) => collected.push(e);
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-fswatch-'));
init(cwd, { projectName: 'fswatch-test', yes: true });
collected.length = 0;
eventBus.on('change', onChange);
stop = startEntityWatcher(cwd);
// fs.watch (FSEvents on macOS) needs a brief moment after watch() before it
// reliably delivers; a write fired in that gap is missed. The server starts
// its watcher long before any write, so this only matters for the test.
await new Promise((r) => setTimeout(r, 250));
});
afterEach(() => {
stop();
eventBus.off('change', onChange);
collected.length = 0;
resetFsWatchErrors();
rmSync(cwd, { recursive: true, force: true });
});
it('emits task/created when a task is written via the service (CLI path)', async () => {
const task = createTask(cwd, { title: 'CLI-created task', role: 'implementer' });
const ev = await waitFor(collected, (e) => e.type === 'task' && e.id === task.id);
expect(ev).toBeDefined();
expect(ev).toMatchObject({
type: 'task',
action: 'created',
id: task.id,
title: 'CLI-created task',
role: 'implementer',
});
}, 4000);
it('emits task/updated when a task file is mutated via the service (claim)', async () => {
const task = createTask(cwd, { title: 'T', role: 'implementer' });
await waitFor(collected, (e) => e.id === task.id); // drain the created event
collected.length = 0;
claimTask(cwd, task.id, 'windows-claude');
const ev = await waitFor(collected, (e) => e.id === task.id && e.action === 'updated');
expect(ev).toBeDefined();
expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' });
}, 4000);
it('reindexes every settled direct task-file edit', async () => {
const task = createTask(cwd, { title: 'manual edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
for (const status of ['in_progress', 'review']) {
const next = readFileSync(file, 'utf-8')
.replace(/^status: .*$/m, `status: ${status}`)
.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date().toISOString()}`);
writeFileSync(file, next);
const deadline = Date.now() + 2000;
while (Date.now() < deadline && listTasks(cwd).find((t) => t.id === task.id)?.status !== status) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe(status);
}
}, 6000);
it('retains the last good index row and exposes invalid frontmatter', async () => {
const task = createTask(cwd, { title: 'invalid edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
writeFileSync(file, readFileSync(file, 'utf-8').replace(/^status: .*$/m, 'status: definitely_invalid'));
await new Promise((resolve) => setTimeout(resolve, 500));
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe('open');
expect(getFsWatchErrors()).toEqual([
expect.objectContaining({ filePath: file, error: expect.stringMatching(/status/i) }),
]);
}, 4000);
});
// ─── 6. fsWatch + REST dedup: each change is delivered exactly once ────────────
describe('fsWatch dedup with the REST emit path', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
const collected: AgentHubEvent[] = [];
const onChange = (e: AgentHubEvent) => collected.push(e);
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-fswatch-dedup-'));
init(cwd, { projectName: 'fswatch-dedup', yes: true });
collected.length = 0;
eventBus.on('change', onChange);
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
eventBus.off('change', onChange);
collected.length = 0;
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('REST POST /tasks emits exactly once (watcher echo suppressed)', async () => {
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'dedup task', role: 'implementer' }),
});
// Wait well past the watcher debounce so any duplicate would have landed.
await new Promise((r) => setTimeout(r, 300));
const forTask = collected.filter((e) => e.type === 'task' && e.id === 'TSK-0001');
expect(forTask).toHaveLength(1);
expect(forTask[0].action).toBe('created');
}, 4000);
});
// ─── 7. status auto-refresh: snapshot stays fresh after mutations ─────────────
describe('status auto-refresh on mutation', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-status-refresh-'));
init(cwd, { projectName: 'status-refresh', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('regenerates status/latest.md with the new task after POST /tasks', async () => {
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Fresh status task', role: 'implementer' }),
});
// Wait past the status-refresh debounce (300 ms) + write.
await new Promise((r) => setTimeout(r, 600));
const status = readFileSync(join(cwd, '.agenthub', 'status', 'latest.md'), 'utf-8');
expect(status).toContain('TSK-0001'); // the just-created task shows as active
}, 4000);
});
// ─── 8. watch --await-review: architect review-queue notifier ─────────────────
describe('watch --await-review', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-await-review-'));
init(cwd, { projectName: 'await-review', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
async function submitForReview(): Promise<void> {
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: t', role: 'implementer' }),
});
await fetch(`${server.url}/tasks/TSK-0001`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'in_progress', assignedTo: 'kimi' }),
});
}
it('returns immediately when a task is already in review (initial check)', async () => {
await submitForReview();
await fetch(`${server.url}/tasks/TSK-0001`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'review' }),
});
await expect(watchEvents(server.url, { awaitReview: true })).resolves.toBeUndefined();
}, 4000);
it('exits when a task transitions to review while watching', async () => {
await submitForReview();
const watching = watchEvents(server.url, { awaitReview: true });
await new Promise((r) => setTimeout(r, 200)); // let it connect + pass the initial check
await fetch(`${server.url}/tasks/TSK-0001`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'review' }),
});
await expect(watching).resolves.toBeUndefined();
}, 5000);
});
// ─── 9. watch --await-message: architect inbox notifier ─────────────────────
describe('watch --await-message', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-await-message-'));
init(cwd, { projectName: 'await-message', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('returns immediately when an unread alias message already exists', async () => {
await fetch(`${server.url}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'windows-claude', to: 'architect', text: 'need review' }),
});
await expect(watchEvents(server.url, { awaitMessage: 'claude' })).resolves.toBeUndefined();
}, 4000);
it('exits when a new message arrives for the watched alias', async () => {
const watching = watchEvents(server.url, { awaitMessage: 'architect', newOnly: true });
await new Promise((r) => setTimeout(r, 200));
await fetch(`${server.url}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'codex', to: 'claude', text: 'ready' }),
});
await expect(watching).resolves.toBeUndefined();
}, 5000);
});