import { describe, it, expect, vi } from 'vitest' import { writable } from 'svelte/store' // computeActivityLog is a pure derivation; it only needs TYPES from the store // modules, so mock their runtime exports to keep the test isolated from the // real store graph (workspace.ts, e.g., starts a top-level setInterval). vi.mock('./chat', () => ({ messages: writable([]), currentSession: writable(null), chatFor: vi.fn(() => ({ messages: writable([]), streaming: writable(false), connectionState: writable('connected'), error: writable(null), notFound: writable(false) })) })) vi.mock('./workspace', () => ({ planSteps: writable([]), currentTask: writable(null), workspaceFor: vi.fn(() => ({})), taskFor: vi.fn(() => writable(null)) })) vi.mock('./execstream', () => ({ liveExecutionOutputFor: vi.fn(() => writable(null)) })) import { computeActivityLog, toolResultSummary } from './activity' import type { ChatMessage } from './chat' import type { PlanStep, Session } from '$lib/api' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) function msg(partial: Partial & { id: string }): ChatMessage { return { id: partial.id, role: 'assistant', text: '', tools: partial.tools ?? [], pendingApprovals: [], created_at: partial.created_at } } function toolResult(name: string, id: string): NonNullable[number] { return { type: 'tool_result', name, id, result: 'ok' } } function toolUse( name: string, id: string, args?: Record ): NonNullable[number] { return { type: 'tool_use', name, id, args } } describe('computeActivityLog timestamps (P0.2)', () => { it('uses the message created_at for persisted tool calls, not a fabricated spread', () => { const created = '2026-07-29T20:08:10Z' const m = msg({ id: 'm1', created_at: created, tools: [toolResult('get_entity', 't1'), toolResult('run', 't2')] }) const entries = computeActivityLog([m], [], null, new Map()) const want = new Date(created).getTime() for (const id of ['t1', 't2']) { const e = entries.find((x) => x.id === id) expect(e, `entry ${id} should exist`).toBeDefined() expect(e!.timestamp).toBe(want) // real time, shared per message — no now-(len-i)*1000 } }) it('freezes live entries (no created_at) so re-derivation never churns them', async () => { const m = msg({ id: 'm1', tools: [toolResult('run', 't1')] }) // no created_at const frozen = new Map() const a = computeActivityLog([m], [], null, frozen) const ts1 = a.find((e) => e.id === 't1')!.timestamp await sleep(60) // the 3s poller re-derives on a later tick const b = computeActivityLog([m], [], null, frozen) const ts2 = b.find((e) => e.id === 't1')!.timestamp expect(ts2).toBe(ts1) // frozen — the previous bug marched every entry forward }) it('is pure w.r.t. wall-clock: two calls with identical inputs give identical output', async () => { const created = '2026-07-29T20:08:10Z' const msgs = [ msg({ id: 'm1', created_at: created, tools: [toolResult('get_entity', 't1')] }), msg({ id: 'm2', created_at: created, tools: [toolResult('run', 't2')] }) ] const steps: PlanStep[] = [ { id: 's1', seq: 1, title: 'A', detail: '', status: 'done', started_at: created } ] const task = { id: 'sess', outcome: 'success', summary: 'done' } as unknown as Session const frozen = new Map() const a = computeActivityLog(msgs, steps, task, frozen) await sleep(50) const b = computeActivityLog(msgs, steps, task, frozen) expect(b.map((e) => [e.id, e.timestamp, e.type])).toEqual( a.map((e) => [e.id, e.timestamp, e.type]) ) }) it('uses a step started_at instead of falling back to now', () => { const started = '2026-07-29T20:07:40Z' const steps: PlanStep[] = [ { id: 's1', seq: 1, title: 'A', detail: '', status: 'done', started_at: started } ] const entries = computeActivityLog([], steps, null, new Map()) expect(entries.find((e) => e.id === 's1')!.timestamp).toBe(new Date(started).getTime()) }) }) // F4 (plan 2026-08-03): the timeline must be generation-aware. A re-proposed // task persists every generation's propose_plan / update_plan_step calls; before // the fix that produced N "Proposed plan" entries and tagged current-gen tools // with seqs inferred from superseded generations. Only the LAST propose_plan is // the live plan; earlier ones collapse to one "Earlier plan revised" marker, and // step-attribution only follows the current generation. describe('computeActivityLog generation awareness (F4)', () => { it('renders one Proposed plan + a revised marker, and attributes tools to the current gen only', () => { const created = '2026-07-29T20:08:10Z' // Generation 1: propose → step 1 running → run. Then generation 2 (re-plan). const gen1 = msg({ id: 'm1', created_at: created, tools: [ toolUse('propose_plan', 'p1'), toolUse('update_plan_step', 'u1', { seq: 1, status: 'running' }), toolResult('run', 'r1') ] }) const gen2 = msg({ id: 'm2', created_at: created, tools: [ toolUse('propose_plan', 'p2'), toolUse('update_plan_step', 'u2', { seq: 1, status: 'running' }), toolResult('run', 'r2') ] }) // Current-generation plan step (gen 2), as fetchPlan (MAX generation) returns. const steps: PlanStep[] = [ { id: 's-gen2', seq: 1, title: 'Gen2 step', detail: '', status: 'done', started_at: created } ] const entries = computeActivityLog([gen1, gen2], steps, null, new Map()) // Exactly one "Proposed plan" (the current generation's). const proposals = entries.filter((e) => e.description === 'Proposed plan') expect(proposals.length).toBe(1) // One collapsed marker for the superseded generation(s). expect(entries.filter((e) => e.description === 'Earlier plan revised').length).toBe(1) // The current-gen run is tagged with step 1 (from gen2's update_plan_step). const r2 = entries.find((e) => e.id === 'r2') expect(r2, 'gen2 run entry should exist').toBeDefined() expect(r2!.stepSeq).toBe(1) // The superseded-gen run is NOT tagged with a current-gen step (its // update_plan_step belonged to the replaced generation). const r1 = entries.find((e) => e.id === 'r1') expect(r1, 'gen1 run entry should exist').toBeDefined() expect(r1!.stepSeq).toBeUndefined() }) it('plan-less Q&A still attributes nothing to a step (no propose_plan at all)', () => { const m = msg({ id: 'm1', tools: [toolResult('get_entity', 't1')] }) const entries = computeActivityLog([m], [], null, new Map()) expect(entries.filter((e) => e.description === 'Proposed plan')).toHaveLength(0) expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined() }) }) // toolResultSummary (chat interaction overhaul): a one-line, humanized outcome // per tool so each inline tool line reads as a result instead of raw JSON. describe('toolResultSummary', () => { type TR = NonNullable[number] const done = (name: string, result: unknown, args?: Record): TR => ({ type: 'tool_result', name, id: name, result, args }) it('is empty for a still-running call and for an errored one', () => { expect(toolResultSummary({ type: 'tool_use', name: 'run', id: 'r' })).toBe('') expect( toolResultSummary({ type: 'tool_result', name: 'run', id: 'r', error: 'boom' }) ).toBe('') }) it('parses run exit status', () => { expect( toolResultSummary(done('run', 'run on lxc:caddy: ERROR exit status 1')) ).toContain('exit 1') }) it('summarizes a clean run with its first line', () => { const s = toolResultSummary(done('run', 'Active: active (running)')) expect(s.startsWith('ok')).toBe(true) expect(s).toContain('active') }) it('formats get_entity as slug (health)', () => { expect( toolResultSummary(done('get_entity', { slug: 'host:hubris', health: 'healthy' })) ).toBe('host:hubris (healthy)') }) it('counts list results', () => { expect( toolResultSummary(done('list_entities', { entities: Array(10).fill({}) })) ).toBe('10 entities') expect(toolResultSummary(done('list_lxcs', { containers: [1, 2] }))).toBe('2 containers') }) it('formats fleet health counts', () => { expect( toolResultSummary( done('get_health_summary', { health: { healthy: 5, degraded: 1, down: 0, unknown: 2 } }) ) ).toBe('healthy 5 · degraded 1 · down 0 · unknown 2') }) it('extracts the knowledge slug from upsert_knowledge', () => { expect( toolResultSummary(done('upsert_knowledge', 'Saved document:nomos/foo-bar to the DB')) ).toBe('recorded document:nomos/foo-bar') }) it('formats update_plan_step from args', () => { expect( toolResultSummary(done('update_plan_step', 'ok', { seq: 2, status: 'done' })) ).toBe('step 2 → done') }) it('counts proposed plan steps', () => { expect(toolResultSummary(done('propose_plan', { steps: [{}, {}, {}] }))).toBe('3 steps') }) it('falls back to the first line for unmapped tools', () => { expect(toolResultSummary(done('some_new_tool', 'first line\nsecond line'))).toBe('first line') }) })