Files
deepseek-harness/packages/ui/desktop/tests/trace-graph.spec.ts
T
NI0317 cdb5524632 feat(ui): render todo plans and streamed tool kinds in both views
todo/write events fold into plan targets: the chat transcript and
trajectory show a checklist card (done counts, active item), and live
ACP plan updates render the same card while the turn streams — the
task list was previously invisible on both paths. Tool rows label
themselves with the streamed ACP kind verb (read/edit/search/run…)
instead of a generic noun, and touched-file locations open in the OS
editor from the expanded row.
2026-07-20 10:25:11 +08:00

144 lines
9.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { buildTraceGraph, type TraceEvent } from '../src/trace-graph.ts'
describe('desktop trace graph', () => {
it('shares one paired tool target across chat, trajectory, waterfall, and inspector payloads', () => {
const graph = buildTraceGraph('s1', fixture())
const tool = graph.targets.get('tool:c1')!
expect(tool.input).toEqual({ command: 'pwd' })
expect(tool.output).toMatchObject({ content: [{ type: 'text', text: '/repo' }], isError: false })
expect(graph.chatTurns[0]?.activities).toContainEqual({ kind: 'tool', targetId: 'tool:c1' })
expect(graph.trajectoryRows.filter(row => row.targetId === 'tool:c1')).toHaveLength(1)
expect(graph.waterfallSpans.filter(span => span.targetId === 'tool:c1')).toHaveLength(1)
expect(graph.trajectoryRows.some(row => row.targetId.includes('result'))).toBe(false)
expect(graph.trajectoryRows.some(row => ['turn', 'step'].includes(graph.targets.get(row.targetId)?.kind ?? ''))).toBe(false)
expect(graph.trajectoryRows.some(row => graph.targets.get(row.targetId)?.kind === 'request')).toBe(false)
})
it('groups multiple model steps into one chat response while preserving selectable blocks', () => {
const graph = buildTraceGraph('s1', fixture())
expect(graph.chatTurns).toHaveLength(1)
expect(graph.chatTurns[0]?.activities.map(activity => activity.targetId)).toEqual([
'reasoning:1:1',
'tool:c1',
'reasoning:1:2',
'assistant:14',
])
expect(graph.trajectoryRows.map(row => row.targetId)).toContain('assistant:6')
expect(graph.targets.get('reasoning:1:1')?.output).toBe('think one')
expect(graph.targets.get('assistant:14')?.output).toEqual([
{ type: 'reasoning', text: 'think two' },
{ type: 'text', text: 'done' },
])
})
it('folds todo/write events into plan targets and chat activities', () => {
const graph = buildTraceGraph('s-plan', [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'go' }] } },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
{ type: 'todo/write', seq: 3, time: 4, data: { turn: 1, step: 1, todos: [
{ content: 'read the code', status: 'completed' },
{ content: 'fix the bug', status: 'in_progress' },
] } },
{ type: 'todo/write', seq: 4, time: 5, data: { turn: 1, step: 1 } },
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
])
const plan = graph.targets.get('plan:3')!
expect(plan.kind).toBe('plan')
expect(plan.output).toEqual([
{ content: 'read the code', status: 'completed' },
{ content: 'fix the bug', status: 'in_progress' },
])
expect(graph.targets.get('plan:4')?.output).toEqual([])
expect(graph.chatTurns[0]?.activities).toContainEqual({ kind: 'plan', targetId: 'plan:3' })
expect(graph.trajectoryRows.filter(row => row.targetId === 'plan:3')).toHaveLength(1)
})
it('normalizes incomplete and malformed event tails without inventing duplicate rows', () => {
expect(buildTraceGraph('empty', []).startTime).toBe(0)
const graph = buildTraceGraph('edge', [
{ type: 'context/message', data: { content: [{ type: 'text', text: 'orphan context' }] } },
{ type: 'turn/end', data: { turn: 99, reason: { kind: 'error' } } },
{ type: 'context/message', data: { turn: 99, content: 'late orphan context' } },
{ type: 'turn/end', data: { turn: 99 } },
{ type: 'step/end', data: { turn: 99, step: 9 } },
{ type: 'turn/start', data: {} },
{ type: 'user/message', data: {} },
{ type: 'step/start', data: { turn: 1 } },
{ type: 'request/header-delta', data: { system: 'delta' } },
{ type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'text-delta', text: 'ignored stream text' } } },
{ type: 'assistant/message', data: { turn: 1, step: 0, content: [{ type: 'reasoning', text: 'fallback reasoning' }] } },
{ type: 'tool/call', data: { turn: 1, step: 0, callId: 'bad', arguments: 'not-json' } },
{ type: 'tool/result', data: { turn: 1, step: 0, callId: 'bad', isError: true, error: 'boom' } },
{ type: 'step/end', data: { turn: 1, step: 0 } },
{ type: 'tool/call', data: { turn: 1, step: 0, callId: 'raw', name: 'raw', rawInput: { value: 1 } } },
{ type: 'tool/call', data: { turn: 1, step: 0, callId: 'whole' } },
{ type: 'tool/result', data: { turn: 1, step: 0, callId: 'missing' } },
{ type: 'steering/message', data: { turn: 1, step: 0, content: [{ type: 'text', text: 'steer' }] } },
{ type: 'turn/start', seq: 20, time: 20, data: { turn: 'bad', trigger: {} } },
{ type: 'step/start', seq: 21, time: 21, data: { turn: 2, step: 1 } },
{ type: 'context/message', seq: 22, time: 22, data: { turn: 2, step: 2, content: 'context' } },
{ type: 'context/message', seq: 23, time: 23, data: { turn: 2, step: 2, content: 'context 2' } },
{ type: 'context/message', seq: 24, time: 24, data: { turn: 2, step: 1 } },
{ type: 'assistant/chunk', seq: 25, time: 25, data: { turn: 2, step: 2, chunk: { type: 'reasoning-delta', text: 'late thought' } } },
{ type: 'context/message', seq: 26, time: 26, data: { turn: 2, step: 2, content: 'return to existing group' } },
{ type: 'assistant/message', seq: 27, time: 27, data: { turn: 2, step: 2 } },
{ type: 'turn/end', seq: 28, time: 28, data: { turn: 2, reason: { kind: 'error' } } },
])
expect(graph.targets.get('tool:bad')).toMatchObject({ title: 'Tool', status: 'error', input: 'not-json' })
expect(graph.targets.get('reasoning:1:0')?.output).toBe('fallback reasoning')
expect(graph.targets.get('context:22')?.output).toBe('context')
expect(graph.targets.get('tool:raw')?.input).toEqual({ value: 1 })
expect(graph.targets.get('tool:whole')?.input).toMatchObject({ callId: 'whole' })
expect(graph.trajectoryRows.filter(row => row.targetId === 'tool:bad')).toHaveLength(1)
expect(graph.trajectoryGroups.some(group => group.status === 'error')).toBe(true)
})
it('covers failure closure and inherited request inputs across later steps', () => {
const graph = buildTraceGraph('branches', [
{ type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: 3, data: { header: { config: { model: 'm' } } } },
{ type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: 'fail', name: 'bash', arguments: {} } },
{ type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: 'fail', isError: true } },
{ type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } },
{ type: 'step/start', seq: 7, time: 7, data: { turn: 1, step: 2 } },
{ type: 'assistant/message', seq: 8, time: 8, data: { turn: 1, step: 2, content: [{ type: 'reasoning', text: 'no local header' }] } },
{ type: 'step/end', seq: 9, time: 9, data: { turn: 1, step: 2 } },
{ type: 'step/start', seq: 10, time: 10, data: { turn: 1, step: 3 } },
{ type: 'assistant/message', seq: 11, time: 11, data: { turn: 1, step: 3, content: [{ type: 'text', text: 'text only' }] } },
{ type: 'steering/message', seq: 12, time: 12, data: { turn: 1, step: 3 } },
{ type: 'unknown/event', seq: 13, time: 13, data: { turn: 1, step: 3 } },
{ type: 'turn/end', seq: 14, time: 14, data: { turn: 1 } },
])
expect(graph.trajectoryGroups.find(group => group.id === 'step:1:1')?.status).toBe('error')
expect(graph.targets.get('reasoning:1:2')?.input).toEqual({ config: { model: 'm' } })
expect(graph.targets.get('context:12')?.output).toBe('')
expect(graph.targets.get('turn:1')?.output).toBe('')
})
})
function fixture(): TraceEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' } } },
{ type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }] } },
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: 3, data: { header: { config: { model: 'm' }, tools: [{ name: 'bash' }] } } },
{ type: 'assistant/chunk', seq: 4, time: 4, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', text: 'think ' } } },
{ type: 'assistant/chunk', seq: 5, time: 5, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', text: 'one' } } },
{ type: 'assistant/message', seq: 6, time: 6, data: { turn: 1, step: 1, content: [{ type: 'reasoning', text: 'think one' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{"command":"pwd"}' }] } },
{ type: 'tool/call', seq: 7, time: 7, data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"command":"pwd"}' } },
{ type: 'tool/result', seq: 8, time: 8, data: { turn: 1, step: 1, callId: 'c1', content: [{ type: 'text', text: '/repo' }], isError: false } },
{ type: 'step/end', seq: 9, time: 9, data: { turn: 1, step: 1 } },
{ type: 'step/start', seq: 10, time: 10, data: { turn: 1, step: 2 } },
{ type: 'request/header', seq: 11, time: 11, data: { header: { config: { model: 'm' }, tools: [{ name: 'bash' }] } } },
{ type: 'assistant/chunk', seq: 12, time: 12, data: { turn: 1, step: 2, chunk: { type: 'reasoning-delta', text: 'think two' } } },
{ type: 'assistant/chunk', seq: 13, time: 13, data: { turn: 1, step: 2, chunk: { type: 'text-delta', text: 'done' } } },
{ type: 'assistant/message', seq: 14, time: 14, data: { turn: 1, step: 2, content: [{ type: 'reasoning', text: 'think two' }, { type: 'text', text: 'done' }] } },
{ type: 'step/end', seq: 15, time: 15, data: { turn: 1, step: 2 } },
{ type: 'turn/end', seq: 16, time: 16, data: { turn: 1, reason: { kind: 'completed' } } },
]
}