agenthub/tests/work.test.ts

114 lines
4.1 KiB
TypeScript

/**
* Tests for `agenthub work` — the auto-claim primitive (TSK-0018).
* - immediate: claims an already-open addressed task without waiting (local).
* - wait: blocks on SSE until a matching task is created, then claims it.
*/
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 { startServer } from '../src/server/index.js';
import { createTask, getTask } from '../src/core/services/taskService.js';
import type { Task } from '../src/core/schema.js';
describe('agenthub work — immediate claim (local)', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-work-'));
init(cwd, { projectName: 'work-test', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('claims an already-open addressed task without waiting', async () => {
const mine = createTask(cwd, { title: 'kimi: already open', role: 'implementer' });
await workAgent({ projectCwd: cwd, agent: 'kimi', role: 'implementer', timeoutSec: 2 });
const { task } = getTask(cwd, mine.id);
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe('kimi');
}, 4000);
});
describe('agenthub work — wait then auto-claim (server SSE)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
let nextServer: Awaited<ReturnType<typeof startServer>> | undefined;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
init(cwd, { projectName: 'work-wait', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close().catch(() => undefined);
await nextServer?.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('claims a task created AFTER work starts waiting', async () => {
// Start waiting; do NOT await yet — no task is addressed to kimi initially.
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 3,
});
// Give work a moment to reach the SSE wait, then delegate a task to kimi.
await new Promise((r) => setTimeout(r, 200));
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: delegated after wait', role: 'implementer' }),
});
await workDone; // resolves once work has claimed the task (or on timeout)
const tasks = (await fetch(`${server.url}/tasks`).then((r) => r.json())) as Task[];
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi:'));
expect(mine).toBeDefined();
expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi');
}, 6000);
it('reconnects after the SSE server moves and claims a later task', async () => {
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 6,
reconnectBackoffMs: [50, 100],
discoverServer: async () => nextServer?.url,
});
await new Promise((r) => setTimeout(r, 200));
server.app.server.closeAllConnections?.();
await server.app.close();
nextServer = await startServer(cwd, { host: '127.0.0.1', port: 0 });
await fetch(`${nextServer.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: delegated after reconnect', role: 'implementer' }),
});
await workDone;
const tasks = (await fetch(`${nextServer.url}/tasks`).then((r) => r.json())) as Task[];
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi: delegated after reconnect'));
expect(mine).toBeDefined();
expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi');
}, 8000);
});