diff --git a/src/app/api/agents/[id]/heartbeat/route.ts b/src/app/api/agents/[id]/heartbeat/route.ts index 7b85049..3fad965 100644 --- a/src/app/api/agents/[id]/heartbeat/route.ts +++ b/src/app/api/agents/[id]/heartbeat/route.ts @@ -180,7 +180,7 @@ export async function GET( * - connection_id: update direct_connections.last_heartbeat * - status: agent status override * - last_activity: activity description - * - token_usage: { model, inputTokens, outputTokens } for inline token reporting + * - token_usage: { model, inputTokens, outputTokens, taskId? } for inline token reporting */ export async function POST( request: NextRequest, @@ -221,10 +221,35 @@ export async function POST( if (agent) { const sessionId = `${agent.name}:cli`; + const parsedTaskId = + token_usage.taskId != null && Number.isFinite(Number(token_usage.taskId)) + ? Number(token_usage.taskId) + : null + + let taskId: number | null = null + if (parsedTaskId && parsedTaskId > 0) { + const taskRow = db.prepare( + 'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?' + ).get(parsedTaskId, workspaceId) as { id?: number } | undefined + if (taskRow?.id) { + taskId = taskRow.id + } else { + logger.warn({ taskId: parsedTaskId, workspaceId, agent: agent.name }, 'Ignoring token usage with unknown taskId') + } + } + db.prepare( - `INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at) - VALUES (?, ?, ?, ?, ?)` - ).run(token_usage.model, sessionId, token_usage.inputTokens, token_usage.outputTokens, now); + `INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at, workspace_id, task_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + token_usage.model, + sessionId, + token_usage.inputTokens, + token_usage.outputTokens, + now, + workspaceId, + taskId + ); tokenRecorded = true; } } diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index bce462b..87e58cc 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -8,6 +8,7 @@ import { logger } from '@/lib/logger' import { getDatabase } from '@/lib/db' import { calculateTokenCost } from '@/lib/token-pricing' import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions' +import { buildTaskCostReport, type TaskCostMetadata } from '@/lib/task-costs' const DATA_PATH = config.tokensPath @@ -22,6 +23,8 @@ interface TokenUsageRecord { totalTokens: number cost: number operation: string + taskId?: number | null + workspaceId?: number duration?: number } @@ -40,6 +43,8 @@ interface ExportData { sessions: Record } +interface TaskMetadataRow extends TaskCostMetadata {} + function extractAgentName(sessionId: string): string { const trimmed = sessionId.trim() if (!trimmed) return 'unknown' @@ -53,18 +58,21 @@ interface DbTokenUsageRow { session_id: string input_tokens: number output_tokens: number + task_id?: number | null + workspace_id?: number created_at: number } -function loadTokenDataFromDb(providerSubscriptions: Record): TokenUsageRecord[] { +function loadTokenDataFromDb(workspaceId: number, providerSubscriptions: Record): TokenUsageRecord[] { try { const db = getDatabase() const rows = db.prepare(` - SELECT id, model, session_id, input_tokens, output_tokens, created_at + SELECT id, model, session_id, input_tokens, output_tokens, task_id, workspace_id, created_at FROM token_usage + WHERE workspace_id = ? ORDER BY created_at DESC, id DESC LIMIT 10000 - `).all() as DbTokenUsageRow[] + `).all(workspaceId) as DbTokenUsageRow[] return rows.map((row) => { const totalTokens = row.input_tokens + row.output_tokens @@ -79,6 +87,8 @@ function loadTokenDataFromDb(providerSubscriptions: Record): To totalTokens, cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }), operation: 'heartbeat', + taskId: row.task_id ?? null, + workspaceId: row.workspace_id ?? workspaceId, } }) } catch (error) { @@ -107,6 +117,8 @@ function normalizeTokenRecord( totalTokens, cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })), operation: String(record.operation ?? 'chat_completion'), + taskId: record.taskId != null && Number.isFinite(Number(record.taskId)) ? Number(record.taskId) : null, + workspaceId: record.workspaceId != null && Number.isFinite(Number(record.workspaceId)) ? Number(record.workspaceId) : 1, duration: record.duration, } } @@ -124,6 +136,8 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] { record.outputTokens, record.totalTokens, record.operation, + record.taskId ?? '', + record.workspaceId ?? 1, record.duration ?? '', ].join('|') if (seen.has(key)) continue @@ -134,7 +148,7 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] { return deduped } -async function loadTokenDataFromFile(providerSubscriptions: Record): Promise { +async function loadTokenDataFromFile(workspaceId: number, providerSubscriptions: Record): Promise { try { ensureDirExists(dirname(DATA_PATH)) await access(DATA_PATH) @@ -145,6 +159,11 @@ async function loadTokenDataFromFile(providerSubscriptions: Record) => normalizeTokenRecord(record, providerSubscriptions)) .filter((record): record is TokenUsageRecord => record !== null) + .filter((record) => { + if (record.workspaceId === workspaceId) return true + // Backward compatibility for pre-workspace records + return workspaceId === 1 && (!record.workspaceId || record.workspaceId === 1) + }) } catch { return [] } @@ -153,24 +172,24 @@ async function loadTokenDataFromFile(providerSubscriptions: Record { +async function loadTokenData(workspaceId: number): Promise { const providerSubscriptions = getProviderSubscriptionFlags() - const dbRecords = loadTokenDataFromDb(providerSubscriptions) - const fileRecords = await loadTokenDataFromFile(providerSubscriptions) + const dbRecords = loadTokenDataFromDb(workspaceId, providerSubscriptions) + const fileRecords = await loadTokenDataFromFile(workspaceId, providerSubscriptions) const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp) if (combined.length > 0) { return combined } // Final fallback: derive from in-memory sessions - return deriveFromSessions(providerSubscriptions) + return deriveFromSessions(workspaceId, providerSubscriptions) } /** * Derive token usage records from OpenClaw session stores. * Each session has totalTokens, inputTokens, outputTokens, model, etc. */ -function deriveFromSessions(providerSubscriptions: Record): TokenUsageRecord[] { +function deriveFromSessions(workspaceId: number, providerSubscriptions: Record): TokenUsageRecord[] { const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age const records: TokenUsageRecord[] = [] @@ -192,6 +211,8 @@ function deriveFromSessions(providerSubscriptions: Record): Tok totalTokens, cost, operation: session.chatType || 'chat', + taskId: null, + workspaceId, }) } @@ -253,17 +274,48 @@ function filterByTimeframe(records: TokenUsageRecord[], timeframe: string): Toke return records.filter(record => record.timestamp >= cutoffTime) } +function loadTaskMetadataById(workspaceId: number, taskIds: number[]): Record { + if (taskIds.length === 0) return {} + const db = getDatabase() + const placeholders = taskIds.map(() => '?').join(', ') + const rows = db.prepare(` + SELECT + t.id, + t.title, + t.status, + t.priority, + t.assigned_to, + t.project_id, + p.name as project_name, + p.slug as project_slug, + p.ticket_prefix as project_prefix, + t.project_ticket_no + FROM tasks t + LEFT JOIN projects p + ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.workspace_id = ? + AND t.id IN (${placeholders}) + `).all(workspaceId, ...taskIds) as TaskMetadataRow[] + + const out: Record = {} + for (const row of rows) { + out[row.id] = row + } + return out +} + export async function GET(request: NextRequest) { const auth = requireRole(request, 'viewer') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) try { const { searchParams } = new URL(request.url) - const action = searchParams.get('action') || 'list' + const action = (searchParams.get('action') || 'list').trim().toLowerCase() const timeframe = searchParams.get('timeframe') || 'all' const format = searchParams.get('format') || 'json' - const tokenData = await loadTokenData() + const workspaceId = auth.user.workspace_id ?? 1 + const tokenData = await loadTokenData(workspaceId) const filteredData = filterByTimeframe(tokenData, timeframe) if (action === 'list') { @@ -377,6 +429,34 @@ export async function GET(request: NextRequest) { }) } + if (action === 'task-costs' || action === 'task_costs' || action === 'taskcosts') { + const attributedTaskIds = [...new Set( + filteredData + .map((record) => record.taskId) + .filter((taskId): taskId is number => Number.isFinite(taskId) && Number(taskId) > 0) + .map((taskId) => Number(taskId)) + )] + const taskMetadataById = loadTaskMetadataById(workspaceId, attributedTaskIds) + const report = buildTaskCostReport( + filteredData.map((record) => ({ + model: record.model, + agentName: record.agentName || extractAgentName(record.sessionId), + timestamp: record.timestamp, + totalTokens: record.totalTokens, + cost: record.cost, + taskId: record.taskId ?? null, + })), + taskMetadataById + ) + + return NextResponse.json({ + ...report, + timeframe, + recordCount: filteredData.length, + attributedRecordCount: filteredData.filter((record) => Number.isFinite(record.taskId)).length, + }) + } + if (action === 'export') { const overallStats = calculateStats(filteredData) const modelStats: Record = {} @@ -468,7 +548,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ trends, timeframe }) } - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + return NextResponse.json({ error: 'Invalid action', action }, { status: 400 }) } catch (error) { logger.error({ err: error }, 'Tokens API error') return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) @@ -481,7 +561,8 @@ export async function POST(request: NextRequest) { try { const body = await request.json() - const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration } = body + const workspaceId = auth.user.workspace_id ?? 1 + const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration, taskId } = body if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) @@ -490,6 +571,19 @@ export async function POST(request: NextRequest) { const totalTokens = inputTokens + outputTokens const providerSubscriptions = getProviderSubscriptionFlags() const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions }) + const parsedTaskId = + taskId != null && Number.isFinite(Number(taskId)) && Number(taskId) > 0 + ? Number(taskId) + : null + + let validatedTaskId: number | null = null + if (parsedTaskId) { + const db = getDatabase() + const taskRow = db.prepare( + 'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?' + ).get(parsedTaskId, workspaceId) as { id?: number } | undefined + if (taskRow?.id) validatedTaskId = taskRow.id + } const record: TokenUsageRecord = { id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, @@ -502,11 +596,13 @@ export async function POST(request: NextRequest) { totalTokens, cost, operation, + taskId: validatedTaskId, + workspaceId, duration, } // Persist only manually posted usage records in the JSON file. - const existingData = await loadTokenDataFromFile(providerSubscriptions) + const existingData = await loadTokenDataFromFile(workspaceId, providerSubscriptions) existingData.unshift(record) if (existingData.length > 10000) { diff --git a/src/lib/__tests__/task-costs.test.ts b/src/lib/__tests__/task-costs.test.ts new file mode 100644 index 0000000..4ab3b2f --- /dev/null +++ b/src/lib/__tests__/task-costs.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { buildTaskCostReport, calculateStats, type TaskCostMetadata, type TokenCostRecord } from '@/lib/task-costs' + +describe('task-cost analytics', () => { + it('calculates stats correctly', () => { + const stats = calculateStats([ + { model: 'a', agentName: 'alpha', timestamp: 1000, totalTokens: 100, cost: 0.1 }, + { model: 'b', agentName: 'alpha', timestamp: 2000, totalTokens: 300, cost: 0.3 }, + ]) + + expect(stats.totalTokens).toBe(400) + expect(stats.totalCost).toBeCloseTo(0.4) + expect(stats.requestCount).toBe(2) + expect(stats.avgTokensPerRequest).toBe(200) + expect(stats.avgCostPerRequest).toBeCloseTo(0.2) + }) + + it('builds task, agent, project and unattributed rollups', () => { + const records: TokenCostRecord[] = [ + { model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T01:00:00Z'), totalTokens: 100, cost: 0.1, taskId: 101 }, + { model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T02:00:00Z'), totalTokens: 150, cost: 0.15, taskId: 101 }, + { model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:00:00Z'), totalTokens: 50, cost: 0.02, taskId: 202 }, + { model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:30:00Z'), totalTokens: 75, cost: 0.03 }, + ] + + const taskMetadata: Record = { + 101: { + id: 101, + title: 'Task One', + status: 'in_progress', + priority: 'high', + assigned_to: 'alpha', + project_id: 1, + project_name: 'Core', + project_slug: 'core', + project_prefix: 'CORE', + project_ticket_no: 12, + }, + 202: { + id: 202, + title: 'Task Two', + status: 'assigned', + priority: 'medium', + assigned_to: 'beta', + project_id: 2, + project_name: 'Ops', + project_slug: 'ops', + project_prefix: 'OPS', + project_ticket_no: 7, + }, + } + + const report = buildTaskCostReport(records, taskMetadata) + + expect(report.tasks).toHaveLength(2) + expect(report.tasks[0]?.taskId).toBe(101) + expect(report.tasks[0]?.stats.totalCost).toBeCloseTo(0.25) + expect(report.tasks[0]?.project.ticketRef).toBe('CORE-012') + + expect(report.agents.alpha?.stats.totalCost).toBeCloseTo(0.25) + expect(report.agents.alpha?.taskIds).toEqual([101]) + expect(report.agents.beta?.taskIds).toEqual([202]) + + expect(report.projects['1']?.taskCount).toBe(1) + expect(report.projects['2']?.taskCount).toBe(1) + + expect(report.summary.totalCost).toBeCloseTo(0.27) + expect(report.unattributed.totalCost).toBeCloseTo(0.03) + }) +}) diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 0b1fc70..f20d365 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -753,6 +753,26 @@ const migrations: Migration[] = [ } } } + }, + { + id: '025_token_usage_task_attribution', + up: (db) => { + const hasTokenUsageTable = db + .prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'token_usage'`) + .get() as { ok?: number } | undefined + + if (!hasTokenUsageTable?.ok) return + + const cols = db.prepare(`PRAGMA table_info(token_usage)`).all() as Array<{ name: string }> + const hasCol = (name: string) => cols.some((c) => c.name === name) + + if (!hasCol('task_id')) { + db.exec(`ALTER TABLE token_usage ADD COLUMN task_id INTEGER`) + } + + db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_task_id ON token_usage(task_id)`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_task_time ON token_usage(workspace_id, task_id, created_at)`) + } } ] diff --git a/src/lib/task-costs.ts b/src/lib/task-costs.ts new file mode 100644 index 0000000..bd1b066 --- /dev/null +++ b/src/lib/task-costs.ts @@ -0,0 +1,221 @@ +export interface TokenCostRecord { + model: string + agentName: string + timestamp: number + totalTokens: number + cost: number + taskId?: number | null +} + +export interface TokenStats { + totalTokens: number + totalCost: number + requestCount: number + avgTokensPerRequest: number + avgCostPerRequest: number +} + +export interface TaskCostMetadata { + id: number + title: string + status: string + priority: string + assigned_to?: string | null + project_id?: number | null + project_name?: string | null + project_slug?: string | null + project_ticket_no?: number | null + project_prefix?: string | null +} + +export interface TaskCostEntry { + taskId: number + title: string + status: string + priority: string + assignedTo?: string | null + project: { + id?: number | null + name?: string | null + slug?: string | null + ticketRef?: string | null + } + stats: TokenStats + models: Record + timeline: Array<{ date: string; cost: number; tokens: number }> +} + +export interface AgentTaskCostEntry { + stats: TokenStats + taskCount: number + taskIds: number[] +} + +export interface ProjectTaskCostEntry { + stats: TokenStats + taskCount: number + taskIds: number[] +} + +export interface TaskCostReport { + summary: TokenStats + tasks: TaskCostEntry[] + agents: Record + projects: Record + unattributed: TokenStats +} + +export function calculateStats(records: TokenCostRecord[]): TokenStats { + if (records.length === 0) { + return { + totalTokens: 0, + totalCost: 0, + requestCount: 0, + avgTokensPerRequest: 0, + avgCostPerRequest: 0, + } + } + + const totalTokens = records.reduce((sum, r) => sum + r.totalTokens, 0) + const totalCost = records.reduce((sum, r) => sum + r.cost, 0) + const requestCount = records.length + + return { + totalTokens, + totalCost, + requestCount, + avgTokensPerRequest: Math.round(totalTokens / requestCount), + avgCostPerRequest: totalCost / requestCount, + } +} + +function groupByModel(records: TokenCostRecord[]): Record { + const modelGroups: Record = {} + for (const record of records) { + if (!modelGroups[record.model]) modelGroups[record.model] = [] + modelGroups[record.model].push(record) + } + + const result: Record = {} + for (const [model, modelRecords] of Object.entries(modelGroups)) { + result[model] = calculateStats(modelRecords) + } + return result +} + +function buildTimeline(records: TokenCostRecord[]): Array<{ date: string; cost: number; tokens: number }> { + const byDate: Record = {} + + for (const record of records) { + const date = new Date(record.timestamp).toISOString().split('T')[0] + if (!byDate[date]) { + byDate[date] = { cost: 0, tokens: 0 } + } + byDate[date].cost += record.cost + byDate[date].tokens += record.totalTokens + } + + return Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, totals]) => ({ date, ...totals })) +} + +function formatTicketRef(prefix?: string | null, num?: number | null): string | null { + if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return null + return `${prefix}-${String(num).padStart(3, '0')}` +} + +export function buildTaskCostReport(records: TokenCostRecord[], taskMetadata: Record): TaskCostReport { + const attributedRecords = records.filter((record) => Number.isFinite(record.taskId)) + const unattributedRecords = records.filter((record) => !Number.isFinite(record.taskId)) + + const byTask: Record = {} + for (const record of attributedRecords) { + const taskId = Number(record.taskId) + if (!taskMetadata[taskId]) continue + if (!byTask[taskId]) byTask[taskId] = [] + byTask[taskId].push(record) + } + + const tasks: TaskCostEntry[] = Object.entries(byTask) + .map(([taskIdRaw, taskRecords]) => { + const taskId = Number(taskIdRaw) + const meta = taskMetadata[taskId] + return { + taskId, + title: meta.title, + status: meta.status, + priority: meta.priority, + assignedTo: meta.assigned_to || null, + project: { + id: meta.project_id ?? null, + name: meta.project_name ?? null, + slug: meta.project_slug ?? null, + ticketRef: formatTicketRef(meta.project_prefix, meta.project_ticket_no), + }, + stats: calculateStats(taskRecords), + models: groupByModel(taskRecords), + timeline: buildTimeline(taskRecords), + } + }) + .sort((a, b) => b.stats.totalCost - a.stats.totalCost) + + const byAgent: Record = {} + for (const record of attributedRecords) { + const taskId = Number(record.taskId) + if (!taskMetadata[taskId]) continue + if (!byAgent[record.agentName]) byAgent[record.agentName] = [] + byAgent[record.agentName].push(record) + } + + const agentTaskIds: Record> = {} + for (const task of tasks) { + const taskRecords = byTask[task.taskId] || [] + for (const record of taskRecords) { + const agent = record.agentName + if (!agentTaskIds[agent]) agentTaskIds[agent] = new Set() + agentTaskIds[agent].add(task.taskId) + } + } + + const agents: Record = {} + for (const [agent, agentRecords] of Object.entries(byAgent)) { + const taskIds = [...(agentTaskIds[agent] || new Set())].sort((a, b) => a - b) + agents[agent] = { + stats: calculateStats(agentRecords), + taskCount: taskIds.length, + taskIds, + } + } + + const byProject: Record = {} + const projectTaskIds: Record> = {} + for (const record of attributedRecords) { + const taskId = Number(record.taskId) + const meta = taskMetadata[taskId] + if (!meta) continue + const key = meta.project_id ? String(meta.project_id) : 'unscoped' + if (!byProject[key]) byProject[key] = [] + byProject[key].push(record) + if (!projectTaskIds[key]) projectTaskIds[key] = new Set() + projectTaskIds[key].add(taskId) + } + + const projects: Record = {} + for (const [projectKey, projectRecords] of Object.entries(byProject)) { + const taskIds = [...(projectTaskIds[projectKey] || new Set())].sort((a, b) => a - b) + projects[projectKey] = { + stats: calculateStats(projectRecords), + taskCount: taskIds.length, + taskIds, + } + } + + return { + summary: calculateStats(attributedRecords.filter((record) => Number.isFinite(record.taskId) && taskMetadata[Number(record.taskId)])), + tasks, + agents, + projects, + unattributed: calculateStats(unattributedRecords), + } +} diff --git a/tests/agent-costs.spec.ts b/tests/agent-costs.spec.ts index 1eb8d09..9b14633 100644 --- a/tests/agent-costs.spec.ts +++ b/tests/agent-costs.spec.ts @@ -97,4 +97,64 @@ test.describe('Agent Costs API', () => { const res = await request.get('/api/tokens?action=agent-costs&timeframe=all') expect(res.status()).toBe(401) }) + + test('GET action=task-costs returns task-level attribution and unattributed rollup', async ({ request }) => { + const agentName = `e2e-taskcost-agent-${Date.now()}` + const createTaskRes = await request.post('/api/tasks', { + headers: API_KEY_HEADER, + data: { + title: `E2E Task Cost ${Date.now()}`, + description: 'Task cost attribution test', + assigned_to: agentName, + }, + }) + expect(createTaskRes.status()).toBe(201) + const createdTask = await createTaskRes.json() + const taskId = createdTask.task.id as number + + const postAttributed = await request.post('/api/tokens', { + headers: API_KEY_HEADER, + data: { + model: 'claude-sonnet-4', + sessionId: `${agentName}:chat`, + inputTokens: 300, + outputTokens: 100, + taskId, + }, + }) + expect(postAttributed.status()).toBe(200) + + const postUnattributed = await request.post('/api/tokens', { + headers: API_KEY_HEADER, + data: { + model: 'claude-haiku-3.5', + sessionId: `${agentName}:chat`, + inputTokens: 50, + outputTokens: 50, + }, + }) + expect(postUnattributed.status()).toBe(200) + + const res = await request.get('/api/tokens?action=task-costs&timeframe=hour', { + headers: API_KEY_HEADER, + }) + const responseText = await res.text() + expect(res.status(), responseText).toBe(200) + const body = JSON.parse(responseText) + + expect(body).toHaveProperty('summary') + expect(body).toHaveProperty('tasks') + expect(body).toHaveProperty('agents') + expect(body).toHaveProperty('projects') + expect(body).toHaveProperty('unattributed') + expect(Array.isArray(body.tasks)).toBe(true) + + const matchingTask = body.tasks.find((task: any) => task.taskId === taskId) + expect(matchingTask).toBeTruthy() + expect(matchingTask.title).toBe(createdTask.task.title) + expect(matchingTask.stats.totalTokens).toBe(400) + expect(matchingTask.stats.requestCount).toBeGreaterThanOrEqual(1) + expect(body.agents[agentName].taskIds).toContain(taskId) + expect(body.unattributed.requestCount).toBeGreaterThanOrEqual(1) + }) })