/** * 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>; let nextServer: Awaited> | 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); // ── TSK-0119: after a review submit the re-armed work loop must stay reachable ── it('wakes on an architect follow-up message instead of going dormant', async () => { // The implementer re-armed `work` after submitting; no task is addressed yet. const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: 'kimi', role: 'implementer', timeoutSec: 4, }); await new Promise((r) => setTimeout(r, 200)); await fetch(`${server.url}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'claude', to: 'kimi', text: 'quick question about your submission' }), }); await workDone; // resolves because the message woke the loop (not on timeout) // The loop drained + marked the message read — proving it woke on the message, // not that it merely timed out (a timeout would leave it delivered/unread). const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>; const m = inbox.find((x) => x.text.includes('quick question')); expect(m).toBeDefined(); expect(m?.status).toBe('read'); }, 7000); it('wakes and re-claims when a submitted task is reopened', async () => { // Seed a task addressed to kimi, claim it, submit for review. await fetch(`${server.url}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'kimi: build the widget', role: 'implementer' }), }); await fetch(`${server.url}/tasks/TSK-0001`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'in_progress', assignedTo: 'kimi' }), }); await fetch(`${server.url}/tasks/TSK-0001`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'review' }), }); // Re-armed work loop: the task is in review, so nothing is addressed/open yet. const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: 'kimi', role: 'implementer', timeoutSec: 4, }); await new Promise((r) => setTimeout(r, 200)); // Architect reopens (send back to implementer). await fetch(`${server.url}/tasks/TSK-0001`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'open' }), }); await workDone; const { task } = getTask(cwd, 'TSK-0001'); expect(task.status).toBe('in_progress'); expect(task.assignedTo).toBe('kimi'); }, 7000); });