231 lines
9.1 KiB
TypeScript
231 lines
9.1 KiB
TypeScript
/**
|
|
* Regression tests for TSK-0230 (HOF-0086): realtime auto-wake / auto-claim.
|
|
*
|
|
* An agent sitting in the work-wait — CLI `agenthub work` AND the MCP
|
|
* `agenthub_work` path (waitForTask) — must wake with NO manual poke when
|
|
* (a) the architect assigns an existing open task to it (task_assign), or
|
|
* (b) a message arrives for it,
|
|
* over the SSE stream AND over the polling fallback alone (SSE down).
|
|
* With default settings the auto-claim must land within ≤10s.
|
|
*
|
|
* Background: TSK-0230 was assigned + messaged to kimi-ah and the waiting
|
|
* loop did not claim it — the CEO had to poke the agent manually. These
|
|
* tests pin the wake path so that class of failure cannot regress.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { init } from '../src/cli/commands/init.js';
|
|
import { workAgent } from '../src/cli/commands/work.js';
|
|
import { findAddressedOpenTask, type AgentContext } from '../src/cli/commands/start.js';
|
|
import { remoteClient } from '../src/cli/remoteClient.js';
|
|
import { waitForTask } from '../src/mcp/server.js';
|
|
import { startServer } from '../src/server/index.js';
|
|
import type { Task } from '../src/core/schema.js';
|
|
|
|
const AGENT = 'kimi';
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
/** Create an open implementer task that is NOT addressed to AGENT. */
|
|
async function createUnaddressedTask(serverUrl: string): Promise<string> {
|
|
const res = await fetch(`${serverUrl}/tasks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: 'someone-else: not for kimi', role: 'implementer' }),
|
|
});
|
|
const task = (await res.json()) as Task;
|
|
return task.id;
|
|
}
|
|
|
|
/** The architect's task_assign: address the open task to AGENT (no claim). */
|
|
async function assignTask(serverUrl: string, id: string): Promise<void> {
|
|
await fetch(`${serverUrl}/tasks/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ assignedTo: AGENT }),
|
|
});
|
|
}
|
|
|
|
async function postMessage(serverUrl: string, text: string): Promise<void> {
|
|
await fetch(`${serverUrl}/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from: 'claude', to: AGENT, text }),
|
|
});
|
|
}
|
|
|
|
async function getTaskViaApi(serverUrl: string, id: string): Promise<Task> {
|
|
// GET /tasks/:id returns { task, body }, not a flat Task.
|
|
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
|
|
return res.task;
|
|
}
|
|
|
|
/**
|
|
* Simulate "SSE down, REST fine": requests to /events hang open without ever
|
|
* delivering a frame (the nasty case — a dropped event on an otherwise-open
|
|
* stream), everything else passes through to the real fetch. The wait may
|
|
* then only wake via the polling fallback (TSK-0224).
|
|
*/
|
|
function breakSseOnly(): void {
|
|
const realFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
if (new URL(url).pathname === '/events') {
|
|
return new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () =>
|
|
reject(new DOMException('The operation was aborted.', 'AbortError')),
|
|
);
|
|
});
|
|
}
|
|
return realFetch(input, init);
|
|
}) as typeof fetch;
|
|
}
|
|
|
|
describe('auto-wake on task_assign / message (TSK-0230)', () => {
|
|
let cwd: string;
|
|
let server: Awaited<ReturnType<typeof startServer>>;
|
|
const realFetch = globalThis.fetch;
|
|
|
|
beforeEach(async () => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-autowake-'));
|
|
init(cwd, { projectName: 'autowake-test', yes: true });
|
|
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
globalThis.fetch = realFetch;
|
|
await server.app.close().catch(() => undefined);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── CLI work loop ─────────────────────────────────────────────────────────
|
|
|
|
it('CLI: auto-claims an assigned task via SSE (no poke)', async () => {
|
|
const taskId = await createUnaddressedTask(server.url);
|
|
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 8 });
|
|
await sleep(200);
|
|
|
|
const t0 = Date.now();
|
|
await assignTask(server.url, taskId);
|
|
await workDone;
|
|
// SSE is live: the wake must be near-instant, far below the 10s bound.
|
|
expect(Date.now() - t0).toBeLessThan(2_000);
|
|
|
|
const task = await getTaskViaApi(server.url, taskId);
|
|
expect(task.status).toBe('in_progress');
|
|
expect(task.assignedTo).toBe(AGENT);
|
|
}, 12_000);
|
|
|
|
it('CLI: auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only, default interval)', async () => {
|
|
breakSseOnly();
|
|
const taskId = await createUnaddressedTask(server.url);
|
|
// Default pollIntervalMs (4000) on purpose: this is the ≤10s acceptance proof.
|
|
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 10 });
|
|
await sleep(300);
|
|
|
|
const t0 = Date.now();
|
|
await assignTask(server.url, taskId);
|
|
await workDone;
|
|
expect(Date.now() - t0).toBeLessThan(10_000);
|
|
|
|
const task = await getTaskViaApi(server.url, taskId);
|
|
expect(task.status).toBe('in_progress');
|
|
expect(task.assignedTo).toBe(AGENT);
|
|
}, 15_000);
|
|
|
|
it('CLI: wakes on an incoming message with SSE DOWN (delivered, not read)', async () => {
|
|
breakSseOnly();
|
|
const workDone = workAgent({
|
|
serverUrl: server.url,
|
|
projectCwd: cwd,
|
|
agent: AGENT,
|
|
role: 'implementer',
|
|
timeoutSec: 8,
|
|
pollIntervalMs: 400,
|
|
});
|
|
await sleep(300);
|
|
|
|
await postMessage(server.url, 'wake without poke (sse down)');
|
|
await workDone; // resolves because the message woke the loop, not on timeout
|
|
|
|
const inbox = (await fetch(`${server.url}/messages?agent=${AGENT}`).then((r) => r.json())) as Array<{
|
|
text: string;
|
|
status: string;
|
|
}>;
|
|
const m = inbox.find((x) => x.text.includes('wake without poke'));
|
|
expect(m).toBeDefined();
|
|
// Surfaced (unread → delivered) but NOT auto-read — a timeout would have left it unread.
|
|
expect(m?.status).toBe('delivered');
|
|
}, 12_000);
|
|
|
|
// ── MCP agenthub_work path (waitForTask) ──────────────────────────────────
|
|
|
|
/** Mimics the agenthub_work finder: addressed-open-task lookup + claim. */
|
|
function mcpWorkFinder(ctx: AgentContext) {
|
|
return async (url: string) => {
|
|
ctx.serverUrl = url;
|
|
const found = await findAddressedOpenTask(ctx);
|
|
if (!found) return null;
|
|
await remoteClient.claimTask(url, found.task.id, ctx.agent);
|
|
return found.task;
|
|
};
|
|
}
|
|
|
|
it('MCP: waitForTask auto-claims an assigned task via SSE (no poke)', async () => {
|
|
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
|
|
const taskId = await createUnaddressedTask(server.url);
|
|
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 8);
|
|
await sleep(200);
|
|
|
|
const t0 = Date.now();
|
|
await assignTask(server.url, taskId);
|
|
const claimed = await waiting;
|
|
// SSE is live: the wake must be near-instant, far below the 10s bound.
|
|
expect(Date.now() - t0).toBeLessThan(2_000);
|
|
|
|
expect(claimed?.id).toBe(taskId);
|
|
const task = await getTaskViaApi(server.url, taskId);
|
|
expect(task.status).toBe('in_progress');
|
|
expect(task.assignedTo).toBe(AGENT);
|
|
}, 12_000);
|
|
|
|
it('MCP: waitForTask auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only)', async () => {
|
|
breakSseOnly();
|
|
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
|
|
const taskId = await createUnaddressedTask(server.url);
|
|
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 10);
|
|
await sleep(300);
|
|
|
|
const t0 = Date.now();
|
|
await assignTask(server.url, taskId);
|
|
const claimed = await waiting;
|
|
expect(Date.now() - t0).toBeLessThan(10_000);
|
|
|
|
expect(claimed?.id).toBe(taskId);
|
|
const task = await getTaskViaApi(server.url, taskId);
|
|
expect(task.status).toBe('in_progress');
|
|
}, 15_000);
|
|
|
|
it('MCP: waitForTask wakes on an incoming message with SSE DOWN', async () => {
|
|
breakSseOnly();
|
|
const finder = async (url: string) => {
|
|
const msgs = await remoteClient.getInbox(url, AGENT, true);
|
|
return msgs.length ? { messages: msgs } : null;
|
|
};
|
|
const waiting = waitForTask(server.url, finder, 8);
|
|
await sleep(300);
|
|
|
|
await postMessage(server.url, 'mcp message wake (sse down)');
|
|
const hit = await waiting;
|
|
|
|
expect(hit).not.toBeNull();
|
|
expect(hit?.messages[0]?.text).toContain('mcp message wake');
|
|
// listInbox read receipt: surfaced as delivered, never auto-read.
|
|
expect(hit?.messages[0]?.status).toBe('delivered');
|
|
}, 12_000);
|
|
});
|