import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { createTask, claimTask, doneTask } from '../src/core/services/taskService.js'; import { createHandoff } from '../src/core/services/handoffService.js'; import { addMemory } from '../src/core/services/memoryService.js'; import { getTaskActivity } from '../src/core/services/activityService.js'; describe('activityService', () => { let cwd: string; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-act-')); }); afterEach(() => { rmSync(cwd, { recursive: true, force: true }); }); it('returns a "created" event for every task', () => { const task = createTask(cwd, { title: 'Auth refactor', role: 'implementer' }); const items = getTaskActivity(cwd, task.id); expect(items.length).toBeGreaterThanOrEqual(1); const created = items.find((i) => i.kind === 'created'); expect(created).toBeDefined(); expect(created!.at).toBe(task.createdAt); expect(created!.summary).toBe('Task created'); }); it('does not include a status event for an open task', () => { const task = createTask(cwd, { title: 'Open task', role: 'architect' }); const items = getTaskActivity(cwd, task.id); expect(items.every((i) => i.kind !== 'status')).toBe(true); }); it('includes a status event once a task is claimed', () => { const task = createTask(cwd, { title: 'Claim me', role: 'implementer' }); claimTask(cwd, task.id, 'codex'); const items = getTaskActivity(cwd, task.id); const status = items.find((i) => i.kind === 'status'); expect(status).toBeDefined(); expect(status!.summary).toBe('Claimed by codex'); expect(status!.meta?.assignedTo).toBe('codex'); }); it('includes a status event with tokens/duration when task is done with metadata', () => { const task = createTask(cwd, { title: 'Finish me', role: 'implementer' }); doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 }); const items = getTaskActivity(cwd, task.id); const status = items.find((i) => i.kind === 'status'); expect(status).toBeDefined(); expect(status!.summary).toBe('Task completed'); expect(status!.meta?.tokens).toBe(8500); expect(status!.meta?.duration).toBe(120_000); expect(status!.meta?.by).toBe('claude'); }); it('includes handoff events linked by taskId', () => { const task = createTask(cwd, { title: 'Delegated task', role: 'architect' }); createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: task.id, summary: 'Design is ready', }); const items = getTaskActivity(cwd, task.id); const handoff = items.find((i) => i.kind === 'handoff'); expect(handoff).toBeDefined(); expect(handoff!.summary).toBe('Design is ready'); expect(handoff!.actor).toBe('architect → implementer'); expect(handoff!.meta?.fromAgent).toBe('claude'); expect(handoff!.meta?.toAgent).toBe('codex'); }); it('does NOT include handoffs for other tasks', () => { const taskA = createTask(cwd, { title: 'A', role: 'implementer' }); const taskB = createTask(cwd, { title: 'B', role: 'architect' }); createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: taskB.id, summary: 'For B only', }); const items = getTaskActivity(cwd, taskA.id); expect(items.every((i) => i.kind !== 'handoff')).toBe(true); }); it('includes memory/result events linked via relatedTasks', () => { const task = createTask(cwd, { title: 'Build DNS cache', role: 'implementer' }); addMemory(cwd, { title: 'DNS TTL observation', category: 'technical', content: 'Default TTL is 300s', relatedTasks: [task.id], }); const items = getTaskActivity(cwd, task.id); const result = items.find((i) => i.kind === 'result'); expect(result).toBeDefined(); expect(result!.summary).toBe('DNS TTL observation'); expect(result!.actor).toBe('technical'); expect(result!.meta?.category).toBe('technical'); }); it('surfaces tokens and duration from memory in the result meta', () => { const task = createTask(cwd, { title: 'Perf tuning', role: 'implementer' }); addMemory(cwd, { title: 'Profiling results', category: 'implementation', content: '50ms p99', relatedTasks: [task.id], tokens: 2000, duration: 45_000, by: 'kimi', }); const items = getTaskActivity(cwd, task.id); const result = items.find((i) => i.kind === 'result'); expect(result!.meta?.tokens).toBe(2000); expect(result!.meta?.duration).toBe(45_000); expect(result!.meta?.by).toBe('kimi'); }); it('does NOT include memory linked to other tasks', () => { const taskA = createTask(cwd, { title: 'A', role: 'implementer' }); const taskB = createTask(cwd, { title: 'B', role: 'implementer' }); addMemory(cwd, { title: 'About B', category: 'lesson', content: 'lesson', relatedTasks: [taskB.id], }); const items = getTaskActivity(cwd, taskA.id); expect(items.every((i) => i.kind !== 'result')).toBe(true); }); it('returns items sorted by timestamp ascending', () => { const task = createTask(cwd, { title: 'Sorted timeline', role: 'implementer' }); createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: task.id, summary: 'H1' }); addMemory(cwd, { title: 'M1', category: 'lesson', content: 'x', relatedTasks: [task.id] }); claimTask(cwd, task.id, 'codex'); const items = getTaskActivity(cwd, task.id); for (let i = 1; i < items.length; i++) { expect(items[i].at >= items[i - 1].at).toBe(true); } }); it('throws when the task does not exist', () => { expect(() => getTaskActivity(cwd, 'TSK-9999')).toThrow(); }); });