fix(nomos): generation-relative plan seq + real activity timestamps
The plan recorded false history after a re-plan and the activity panel showed fabricated, churning timestamps. Two bugs compounding on one event stream. Plan drift (P0.1): - proposePlan seq is now 1..N per generation; (session,generation,seq) is the addressing key. The model's 1-based update_plan_step calls always map to the CURRENT plan after a re-plan, instead of resurrecting a superseded `replaced` row as done while the live work went unrecorded. - updatePlanStep resolves against MAX(generation); a stale/out-of-range seq returns errPlanStepNotFound (never touches a superseded generation). - getPlanSteps returns only the current generation by default; ?all=true keeps the audit/eval view (plan_generations assertion). - completeTask auto-close scopes to the current gen, stamps started_at, and emits one plan.step.finished per closed step so the panel converges instead of freezing on "running" after completion (P1.1). - propose_plan result enumerates step seqs; writeback detector matches "write back"/"writeback"/"upsert_knowledge" so a natural-language final step isn't doubled (P1.2). - migration 029 renumbers existing seq per generation + unique index. Activity panel (P0.2 / P1.1, web): - computeActivityLog uses the real message created_at for tool calls; live entries fall back to wall-clock frozen on first sight, killing the 3s poll churn. Steps use real started_at. - dropped plan-step events warn + count instead of a silent no-op. Tests: TestProposePlan updated; + generation-relative-seq and auto-close event-emission regression tests; + web activity purity/timestamp tests. VERSION: 0.14.0 -> 0.14.1
This commit is contained in:
103
web/src/lib/stores/activity.test.ts
Normal file
103
web/src/lib/stores/activity.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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([]),
|
||||
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 } 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<ChatMessage> & { 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<ChatMessage['tools']>[number] {
|
||||
return { type: 'tool_result', name, id, result: 'ok' }
|
||||
}
|
||||
|
||||
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<string, number>()
|
||||
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<string, number>()
|
||||
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())
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user