Backend: - Add isThinking flag to agentEvent for text before tool calls - Separate thinking from response text in runChatTurn and continue.go - Persist thinking in a dedicated field in message content Frontend: - Add thinking field to MessageContent, ChatMessage, ChatTextEvent types - Create ThinkingBlock.svelte — collapsible block with brain icon - SSE handler moves text_delta content to thinking on isThinking flag - Render thinking block between tools and response in ChatThread - Fix chat window scroll reset on focus change (stable windowKeys order) - Remove redundant #key id wrapper in WindowLayer - Enlarge sidebar rail (24→32 default, 40→60 max) - Remove glyph from sidebar, square graph at top - Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
248 lines
9.3 KiB
TypeScript
248 lines
9.3 KiB
TypeScript
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<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' }
|
|
}
|
|
|
|
function toolUse(
|
|
name: string,
|
|
id: string,
|
|
args?: Record<string, unknown>
|
|
): NonNullable<ChatMessage['tools']>[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<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())
|
|
})
|
|
})
|
|
|
|
// 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<ChatMessage['tools']>[number]
|
|
const done = (name: string, result: unknown, args?: Record<string, unknown>): 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')
|
|
})
|
|
})
|