agenthub/tests/activityService.test.ts
chahinebrini 9c749beacb feat(activity): per-task timeline endpoint, board expand, --task/--tokens/--duration/--by flags
GET /tasks/:id/activity returns a time-sorted ActivityItem[] assembled from existing data (task created, handoffs by taskId, memory by relatedTasks, current status). memory add + task done gain optional tokens/duration/by metadata surfaced in the timeline. Board cards expand inline to show the timeline. Index gains taskId + relatedTasks columns with migrations. 74 -> 97 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:49:47 +02:00

149 lines
5.8 KiB
TypeScript

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();
});
});