agenthub/tests/taskLog-live.test.ts
chahinebrini 2c2193ec55 feat(agenthub): TSK-0074 — automatic agent progress logging to a task live console
- routes PATCH /tasks/🆔 uniformly appendTaskLog + publishLog for every status
  transition (claim/review/done/cancel/reopen) and on assign; best-effort, never
  fails the mutation. No double-emission (log rides the 'log' channel; fsWatch
  only watches .md so the .log append is not re-emitted).
- MCP agenthub_task_log tool + remoteClient.appendTaskLog + CLI 'task log <id>
  --text [--agent][--level]'
- agenthub_work LOOP reminder: 'Report meaningful progress with agenthub_task_log'
- taskDetail: Live Console panel — historic lines via readTaskLog + live tail via
  the named task-log SSE event filtered to this task id
- tests: +taskLog-live.test.ts (PATCH->one log event, no double change, historic
  render, POST /log, remoteClient)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:02:04 +02:00

106 lines
5.0 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
import { eventBus } from '../src/server/events.js';
import { readTaskLog } from '../src/core/services/taskLogService.js';
import { remoteClient } from '../src/cli/remoteClient.js';
describe('task live console (TSK-0074)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-tasklog-'));
init(cwd, { projectName: 'tasklog-test', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
eventBus.removeAllListeners('log');
eventBus.removeAllListeners('change');
rmSync(cwd, { recursive: true, force: true });
});
it('PATCH in_progress appends a status log line + emits exactly one task-log event (no double change)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
const logs: Array<{ taskId?: string; text?: string; level?: string }> = [];
const changes: unknown[] = [];
const onLog = (p: { taskId?: string; text?: string; level?: string }) => logs.push(p);
const onChange = (e: unknown) => changes.push(e);
eventBus.on('log', onLog);
eventBus.on('change', onChange);
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
eventBus.off('log', onLog);
eventBus.off('change', onChange);
// Exactly one task-log SSE event, carrying the task id + a "Claimed by" line.
expect(logs).toHaveLength(1);
expect(logs[0].taskId).toBe('TSK-0001');
expect(logs[0].text).toContain('Claimed by codex');
expect(logs[0].level).toBe('status');
// No double-emission on the change channel (one emitChange per PATCH).
expect(changes).toHaveLength(1);
// Persisted to the task's .log file.
const persisted = readTaskLog(cwd, 'TSK-0001');
expect(persisted.some((e) => e.text.includes('Claimed by codex') && e.level === 'status')).toBe(true);
});
it('logs every status transition (review, done)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
const texts = readTaskLog(cwd, 'TSK-0001').map((e) => e.text);
expect(texts.some((t) => t.startsWith('Claimed by'))).toBe(true);
expect(texts.some((t) => t.startsWith('Submitted for review'))).toBe(true);
expect(texts.some((t) => t.startsWith('Approved — done'))).toBe(true);
});
it('task detail HTML renders the Live Console with historic lines', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'wrote failing test', agent: 'codex', level: 'info' } });
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } });
expect(res.statusCode).toBe(200);
expect(res.payload).toContain('Live Console');
expect(res.payload).toContain('wrote failing test');
// Live tail wires to the named task-log SSE event, scoped to this task id.
expect(res.payload).toContain("addEventListener('task-log'");
expect(res.payload).toContain('"TSK-0001"');
});
it('POST /tasks/:id/log returns the stored entry', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'green: 12 tests', level: 'status' } });
expect(res.statusCode).toBe(200);
const entry = JSON.parse(res.payload) as { text: string; level: string; ts: string };
expect(entry.text).toBe('green: 12 tests');
expect(entry.level).toBe('status');
expect(entry.ts).toBeTruthy();
});
it('remoteClient.appendTaskLog POSTs to /tasks/:id/log', async () => {
vi.restoreAllMocks();
const stored = { ts: new Date().toISOString(), text: 'progress', level: 'info' };
globalThis.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(stored)),
} as Response);
const res = await remoteClient.appendTaskLog('http://localhost:3377', 'TSK-0001', { text: 'progress', agent: 'codex' });
expect(res.text).toBe('progress');
const call = vi.mocked(fetch).mock.calls[0];
expect(String(call[0])).toBe('http://localhost:3377/tasks/TSK-0001/log');
expect((call[1] as RequestInit).method).toBe('POST');
});
});