import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createTask, claimTask, reviewTask, reopenTask, cancelTask, assignTask } from '../src/core/services/taskService.js'; import { createMessage } from '../src/core/services/messageService.js'; import { resolvePending, hasPending } from '../src/core/services/checkinService.js'; import { createAsk, answerAsk } from '../src/core/services/askService.js'; /** * TSK-0274 — der Check-in-Kanal. * * Kern der Sache: ein Agent, der einen Task AUSFÜHRT, empfängt keine Events. * Diese Tests sichern den einzigen Weg ab, auf dem er trotzdem erfährt, dass * sich etwas an SEINER Arbeit geändert hat. */ let cwd: string; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'agenthub-checkin-')); }); afterEach(() => { rmSync(cwd, { recursive: true, force: true }); }); function openTaskFor(agent: string, title = 'test task') { const task = createTask(cwd, { title: `${agent}: ${title}`, role: 'implementer' }); assignTask(cwd, task.id, agent); return task.id; } describe('resolvePending — Reopen unter laufender Arbeit', () => { it('meldet interrupted, wenn der gehaltene Task zurückgegeben wurde', () => { const id = openTaskFor('codex'); claimTask(cwd, id, 'codex'); reviewTask(cwd, id); reopenTask(cwd, id); const pending = resolvePending(cwd, 'codex', id); expect(pending.interrupted).toBeDefined(); expect(pending.interrupted?.taskId).toBe(id); expect(pending.interrupted?.status).toBe('open'); // Die Handlungsanweisung muss unmissverständlich sein — der Agent soll // NICHT weiterbauen und NICHT einreichen. expect(pending.interrupted?.action).toMatch(/STOPP/); expect(hasPending(pending)).toBe(true); }); it('meldet interrupted, wenn der Architekt eine laufende Arbeit entzieht (in_progress → open)', () => { const id = openTaskFor('codex'); claimTask(cwd, id, 'codex'); reopenTask(cwd, id); // Entzug ohne Umweg über review const pending = resolvePending(cwd, 'codex', id); expect(pending.interrupted?.taskId).toBe(id); expect(pending.interrupted?.status).toBe('open'); }); it('meldet interrupted bei Abbruch', () => { const id = openTaskFor('codex'); claimTask(cwd, id, 'codex'); cancelTask(cwd, id); const pending = resolvePending(cwd, 'codex', id); expect(pending.interrupted?.status).toBe('cancelled'); expect(pending.interrupted?.reason).toMatch(/abgebrochen/i); }); it('meldet KEIN interrupted, solange der Task normal läuft', () => { const id = openTaskFor('codex'); claimTask(cwd, id, 'codex'); const pending = resolvePending(cwd, 'codex', id); expect(pending.interrupted).toBeUndefined(); expect(hasPending(pending)).toBe(false); }); it('verwechselt Agenten nicht — fremder Claim gilt als interrupted', () => { const id = openTaskFor('kimi'); claimTask(cwd, id, 'kimi'); // codex glaubt, an diesem Task zu arbeiten — tut er aber nicht. const pending = resolvePending(cwd, 'codex', id); expect(pending.interrupted?.taskId).toBe(id); }); }); describe('resolvePending — Nachrichten und Zuweisungen', () => { it('liefert ungelesene Nachrichten mit Vorschau', () => { createMessage(cwd, { from: 'claude', to: 'codex', text: 'Bitte Reihenfolge umdrehen.' }); const pending = resolvePending(cwd, 'codex'); expect(pending.unreadCount).toBe(1); expect(pending.messages[0]?.from).toBe('claude'); expect(pending.messages[0]?.preview).toContain('Reihenfolge'); expect(pending.note).toMatch(/ungelesene/i); }); it('nennt offene, an den Agenten adressierte Tasks', () => { const id = openTaskFor('codex'); const pending = resolvePending(cwd, 'codex'); expect(pending.waitingTasks).toContain(id); }); it('nennt eingereichte Tasks, die auf das Review warten', () => { const id = openTaskFor('codex'); claimTask(cwd, id, 'codex'); reviewTask(cwd, id); const pending = resolvePending(cwd, 'codex'); expect(pending.awaitingReview).toContain(id); }); it('bleibt klein, wenn nichts anliegt', () => { const pending = resolvePending(cwd, 'codex'); expect(hasPending(pending)).toBe(false); expect(pending.note).toBeUndefined(); expect(JSON.stringify(pending).length).toBeLessThan(200); }); it('begrenzt die Nachrichten-Vorschau, damit die Antwort klein bleibt', () => { for (let i = 0; i < 12; i += 1) { createMessage(cwd, { from: 'claude', to: 'codex', text: `Nachricht ${i} `.repeat(40) }); } const pending = resolvePending(cwd, 'codex'); expect(pending.unreadCount).toBe(12); expect(pending.messages.length).toBeLessThanOrEqual(3); // Die Antwort fließt in JEDEN Log-Aufruf — sie darf nicht ausufern. expect(JSON.stringify(pending).length).toBeLessThan(1500); }); }); describe('resolvePending — beantwortete Fragen (Ask-Timeout-Lücke)', () => { it('liefert die Antwort auf eine eigene Frage nach — auch lange nach dem 300s-Wait', () => { const ask = createAsk(cwd, { from: 'kimi', to: 'claude', question: 'Welcher Weg?' }); answerAsk(cwd, ask.id, 'Nimm Variante B.', 'claude'); const pending = resolvePending(cwd, 'kimi'); // agenthub_ask(wait) gibt nach 300s auf; braucht der Architekt laenger, // erreicht die Antwort den dormanten Agenten sonst NIE. expect(pending.answeredAsks.map((a) => a.id)).toContain(ask.id); expect(pending.answeredAsks[0]?.answer).toContain('Variante B'); expect(pending.note).toMatch(/BEANTWORTET/); expect(hasPending(pending)).toBe(true); }); it('zeigt fremde Antworten nicht an', () => { const ask = createAsk(cwd, { from: 'codex', to: 'claude', question: 'X?' }); answerAsk(cwd, ask.id, 'Y', 'claude'); expect(resolvePending(cwd, 'kimi').answeredAsks).toHaveLength(0); }); });