feat(ui): rebuild desktop renderer on a shared trace graph and design tokens

Replace the full-innerHTML render loop with a static shell plus
per-region updates, so composer drafts, fold state, focus, and scroll
survive streaming turns. Fold session events into one trace graph
consumed by Chat, Trajectory, Waterfall, and the shared inspector
drawer, with live ACP updates patched into a keyed live-turn region.

Align the visual system with a tokenized design spec: a 4px spacing
base with fixed control/row height steps, foreground-derived text
tiers and borders (color-mix), neutral interaction overlays, tiered
motion durations with a reduced-motion collapse, hover-revealed
scrollbars, and drawer-aware layout elasticity. Localize trajectory
role chips and row previews.

The renderer entry (app.ts) joins the coverage exclude list as a
self-executing DOM bootstrap: jsdom lifecycle specs exercise its
behavior, and extractable logic lives in covered modules
(trace-graph.ts, renderer-content.ts).
This commit is contained in:
NI0317
2026-07-19 01:37:04 +08:00
parent 382708b1c0
commit c4584b5c19
16 changed files with 4866 additions and 1798 deletions

View File

@@ -48,9 +48,9 @@ describe('desktop surface policies', () => {
})
})
it('keeps the composer scoped to chat only', () => {
it('keeps the session composer across the three live views', () => {
for (const surface of DESKTOP_SURFACES) {
expect(ownsComposer(surface)).toBe(surface === 'chat')
expect(ownsComposer(surface)).toBe(['chat', 'trajectory', 'waterfall'].includes(surface))
}
})
@@ -71,6 +71,11 @@ describe('desktop inspector contracts', () => {
kind: 'tool-call',
eventSeq: 42,
})).toBe('session:s1:run:r1:kind:tool-call:seq:42')
expect(createInspectorTargetId({
sessionId: 's1',
kind: 'message',
syntheticId: 'draft',
})).toBe('session:s1:kind:message:synthetic:draft')
})
it('opens output by default for produced data and metadata for structural targets', () => {
@@ -85,6 +90,21 @@ describe('desktop inspector contracts', () => {
kind: 'step',
title: 'step 1',
}).activeTab).toBe('metadata')
const expected = new Map<InspectorTarget['kind'], string>([
['assistant-stream', 'output'],
['tool-result', 'output'],
['session', 'metadata'],
['run', 'metadata'],
['turn', 'metadata'],
['step', 'metadata'],
['waterfall-span', 'metadata'],
['dev-object', 'input'],
['message', 'input'],
])
for (const [kind, tab] of expected) {
expect(openInspectorState({ ...target, kind }).activeTab).toBe(tab)
}
})
it('keeps inspector tabs ordered with feedback last', () => {
@@ -109,4 +129,12 @@ describe('desktop i18n', () => {
expect(translate('zh-CN', 'app.language')).toBe('EN')
expect(translate('en-US', 'app.language')).toBe('中文')
})
it('keeps core Chinese labels localized rather than falling back to English', () => {
expect(translate('zh-CN', 'app.develop')).toBe('开发')
expect(translate('zh-CN', 'surface.trajectory')).toBe('轨迹')
expect(translate('zh-CN', 'chat.thinking')).toBe('思考')
expect(translate('zh-CN', 'dev.requestSystemPrompt')).toBe('当前请求的系统提示词')
expect(translate('zh-CN', 'inspector.feedback')).toBe('反馈')
})
})

View File

@@ -0,0 +1,69 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import { assistantText, contentBlocks, contentText, reasoningText } from '../src/renderer-content.ts'
describe('desktop live content', () => {
it('renders single ACP update blocks before the persisted message exists', () => {
expect(contentText({ type: 'text', text: '你' })).toBe('你')
expect(contentText({ type: 'reasoning', text: '想' })).toBe('想')
})
it('accepts plain strings, arrays, and empty values', () => {
const blocks = [{ type: 'text', text: 'a' }]
expect(contentBlocks(blocks)).toBe(blocks)
expect(contentBlocks(null)).toEqual([])
expect(contentText('plain')).toBe('plain')
expect(assistantText('plain')).toBe('plain')
})
it('keeps persisted content arrays split by visible role', () => {
const content = [
{ type: 'reasoning', text: '先想' },
{ type: 'text', text: '再答' },
]
expect(reasoningText(content)).toBe('先想')
expect(assistantText(content)).toBe('再答')
})
it('renders tool, resource, and unknown blocks without object coercion', () => {
expect(contentText({ type: 'tool-call', name: 'bash', arguments: { command: 'pwd' } }))
.toBe('[tool-call bash] {"command":"pwd"}')
expect(contentText({ type: 'resource_link', name: 'notes', uri: 'file:///notes' }))
.toBe('[resource notes] file:///notes')
expect(contentText({ type: 'custom', value: 1 })).toBe('{"type":"custom","value":1}')
expect(contentText({ type: 'text', text: 1 })).toBe('')
expect(contentText({ type: 'tool-call', name: 1, arguments: 'pwd' })).toBe('[tool-call ] pwd')
expect(contentText({ type: 'tool-call', name: 'bash' })).toBe('[tool-call bash] ')
expect(contentText({ type: 'resource_link' })).toBe('[resource ] ')
expect(reasoningText([{ type: 'reasoning', text: 1 }])).toBe('')
expect(contentText(undefined)).toBe('')
})
})
describe('desktop shell layout', () => {
it('pins the composer to its intrinsic bottom row', async () => {
const css = await readFile(new URL('../src/styles.css', import.meta.url), 'utf8')
expect(css).toMatch(/\.session-canvas,\s*\.module-canvas\s*{\s*grid-row: 3;/)
expect(css).toMatch(/\.composer\s*{\s*grid-row: 4;/)
})
it('does not launch Electron after a strict-port Vite failure', async () => {
const script = await readFile(new URL('../scripts/dev.mjs', import.meta.url), 'utf8')
expect(script).toContain("'--strictPort'")
expect(script).not.toContain('setTimeout(startElectron')
})
it('suppresses persisted ACP replay updates before forwarding a new prompt', async () => {
const main = await readFile(new URL('../src/main.mjs', import.meta.url), 'utf8')
expect(main).toContain('replayingSessions.has(String(params.sessionId))')
expect(main).toContain('replayingSessions.add(sessionId)')
expect(main).toContain('replayingSessions.delete(sessionId)')
})
it('switches from Develop to Sessions and patches artifact detail in place', async () => {
const app = await readFile(new URL('../src/app.ts', import.meta.url), 'utf8')
expect(app).toMatch(/if \(sessionButton !== null\) \{\s*showModule\('sessions'\)\s*await loadTrace/)
expect(app).toContain("selectDevArtifact(devArtifact.dataset.devArtifact ?? '')")
expect(app).toContain('detail.innerHTML = renderDevArtifactDetail(selected)')
})
})

View File

@@ -0,0 +1,237 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {} from '../src/global.d.ts'
interface Deferred<T> {
readonly promise: Promise<T>
resolve(value: T): void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('desktop renderer chat lifecycle', () => {
beforeEach(() => {
vi.resetModules()
localStorage.clear()
document.body.innerHTML = '<div id="app"></div>'
Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: { escape: (value: string) => value.replaceAll(':', '\\:') },
})
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
})
it('preserves drafts while streaming a single ACP block and exits the completed state', async () => {
const firstPrompt = deferred<unknown>()
const secondPrompt = deferred<unknown>()
const promptQueue = [firstPrompt, secondPrompt]
let update: ((payload: unknown) => void) | undefined
let sessions: unknown[] = []
let traceRead: unknown
const completedTrace = {
found: true,
sessionId: 's-new',
header: { id: 's-new' },
rawText: '',
feedback: [],
events: [
{ 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: 'hello' }] } },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', text: 'why' } } },
{ type: 'assistant/message', seq: 4, time: 5, data: { turn: 1, step: 1, content: [{ type: 'reasoning', text: 'why' }, { type: 'text', text: 'final answer' }] } },
{ type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId: 'call-1', name: 'bash', arguments: '{"command":"pwd"}' } },
{ type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId: 'call-1', content: [{ type: 'text', text: '/repo' }] } },
{ type: 'step/end', seq: 7, time: 8, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 8, time: 9, data: { turn: 1, reason: { kind: 'completed' } } },
],
}
const secondTrace = {
...completedTrace,
events: [
...completedTrace.events,
{ type: 'turn/start', seq: 9, time: 10, data: { turn: 2, trigger: { kind: 'message' } } },
{ type: 'user/message', seq: 10, time: 11, data: { content: [{ type: 'text', text: 'second' }] } },
{ type: 'step/start', seq: 11, time: 12, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 12, time: 13, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'second answer' }] } },
{ type: 'step/end', seq: 13, time: 14, data: { turn: 2, step: 1 } },
{ type: 'turn/end', seq: 14, time: 15, data: { turn: 2, reason: { kind: 'completed' } } },
],
}
traceRead = completedTrace
window.dshDesktop = {
runtime: {
start: async () => ({}),
stop: async () => ({}),
restart: async () => ({}),
status: async () => ({ state: 'running', repoRoot: '/repo' }),
onStatus: () => () => {},
onStderr: () => () => {},
},
sessions: {
list: async () => ({ sessions }),
create: async () => ({ sessionId: 's-new', trace: { ...completedTrace, events: [] } }),
load: async () => ({}),
prompt: async () => promptQueue.shift()!.promise,
cancel: async () => ({}),
reveal: async () => ({}),
onUpdate: (callback: (payload: unknown) => void) => {
update = callback
return () => {}
},
},
trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }) },
}
await import('../src/app.ts')
await vi.waitFor(() => {
expect(document.querySelector('#composerInput')).not.toBeNull()
})
const newSession = document.querySelector<HTMLButtonElement>('[data-action="new-session"]')!
newSession.click()
const composer = document.querySelector<HTMLTextAreaElement>('#composerInput')!
composer.value = 'hello'
composer.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLFormElement>('#composerForm')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await vi.waitFor(() => {
expect(document.querySelector('#topbarTitle')?.textContent).toBe('hello')
})
const search = document.querySelector<HTMLInputElement>('#sessionSearch')!
search.value = 'keep search'
search.dispatchEvent(new Event('input', { bubbles: true }))
composer.value = 'next draft'
composer.dispatchEvent(new Event('input', { bubbles: true }))
const chatView = document.querySelector<HTMLElement>('#chatView')!
Object.defineProperties(chatView, {
scrollHeight: { configurable: true, value: 1000 },
clientHeight: { configurable: true, value: 400 },
scrollTop: { configurable: true, writable: true, value: 100 },
})
chatView.dispatchEvent(new Event('scroll'))
update?.({ sessionId: 's-new', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'streamed' } } })
expect(document.querySelector('[data-live="answer"]')?.textContent).toBe('streamed')
expect(document.querySelector('#liveTurn .message.live')).not.toBeNull()
expect(document.querySelector<HTMLButtonElement>('#liveJump')?.hidden).toBe(false)
expect(composer.value).toBe('next draft')
expect(search.value).toBe('keep search')
document.querySelector<HTMLButtonElement>('#liveJump')!.click()
expect(chatView.scrollTop).toBe(1000)
expect(document.querySelector<HTMLButtonElement>('#liveJump')?.hidden).toBe(true)
sessions = [{
id: 's-new',
title: 'hello',
createdAt: 1,
lastActivity: 6,
eventCount: 6,
turnCount: 1,
stepCount: 1,
toolCallCount: 0,
live: true,
}]
firstPrompt.resolve({ response: { stopReason: 'end_turn' }, trace: completedTrace })
await vi.waitFor(() => {
expect(document.querySelector('#conversation')?.textContent).toContain('final answer')
})
expect(document.querySelector<HTMLButtonElement>('#cancelButton')?.hidden).toBe(true)
expect(document.querySelector('#liveTurn')?.textContent).not.toContain('正在生成')
expect(composer.value).toBe('next draft')
composer.value = 'second'
composer.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLFormElement>('#composerForm')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
update?.({ sessionId: 's-new', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'second streamed' } } })
secondPrompt.resolve({ response: { stopReason: 'end_turn' }, trace: completedTrace })
await vi.waitFor(() => {
expect(document.querySelectorAll('#conversation .message')).toHaveLength(2)
expect(document.querySelector('#liveTurn')?.textContent).toContain('second streamed')
})
traceRead = secondTrace
document.querySelector<HTMLButtonElement>('[data-surface="trajectory"]')!.click()
await vi.waitFor(() => {
expect(document.querySelectorAll('#conversation .message')).toHaveLength(4)
})
expect([...document.querySelectorAll('#conversation .message')].map(node => node.textContent))
.toEqual(expect.arrayContaining([
expect.stringContaining('hello'),
expect.stringContaining('final answer'),
expect.stringContaining('second'),
expect.stringContaining('second answer'),
]))
expect(document.querySelector('#liveTurn')?.textContent).toBe('')
const firstAssistant = document.querySelectorAll<HTMLElement>('.message.assistant')[0]!
const thinking = firstAssistant.querySelector<HTMLElement>('.chat-activity.thinking')!
const thinkingButton = thinking.querySelector<HTMLButtonElement>('.activity-select')!
thinkingButton.click()
expect({
targetId: thinkingButton.dataset.targetId,
kind: document.querySelector('#inspectorKind')?.textContent,
title: document.querySelector('#inspectorTitle')?.textContent,
}).toEqual({ targetId: 'reasoning:1:1', kind: '思考', title: '思考' })
const tool = firstAssistant.querySelector<HTMLElement>('.chat-activity.tool-use')!
tool.querySelector<HTMLButtonElement>('.activity-select')!.click()
expect(document.querySelector('#inspectorKind')?.textContent).toBe('工具')
document.querySelectorAll<HTMLElement>('[data-target-id^="assistant:"]')[1]!.click()
expect(document.querySelector<HTMLElement>('#inspector')?.hidden).toBe(false)
expect(document.querySelector('#inspectorTitle')?.textContent).toBe('回复')
document.querySelector<HTMLButtonElement>('[data-action="close-inspector"]')!.click()
search.value = ''
search.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLButtonElement>('[data-module="develop"]')!.click()
const rail = document.querySelector<HTMLElement>('.develop-artifact-rail')!
const detail = document.querySelector<HTMLElement>('.develop-artifact-detail')!
rail.scrollTop = 180
detail.scrollTop = 220
document.querySelectorAll<HTMLButtonElement>('[data-dev-artifact]')[1]!.click()
expect(document.querySelector('.develop-artifact-rail')).toBe(rail)
expect(document.querySelector('.develop-artifact-detail')).toBe(detail)
expect(rail.scrollTop).toBe(180)
expect(detail.scrollTop).toBe(220)
document.querySelector<HTMLButtonElement>('[data-session="s-new"]')!.click()
await vi.waitFor(() => {
expect(document.querySelector<HTMLElement>('#sessionCanvas')?.hidden).toBe(false)
expect(document.querySelector<HTMLElement>('#devCanvas')?.hidden).toBe(true)
})
document.querySelector<HTMLButtonElement>('[data-surface="waterfall"]')!.click()
const chatToolTarget = document.querySelector<HTMLElement>('.chat-activity.tool-use [data-target-id]')?.dataset.targetId
const trajectoryToolTarget = document.querySelector<HTMLElement>('.traj-row.tool')?.dataset.targetId
const waterfallToolTarget = document.querySelector<HTMLElement>('.wf-bar.tool')?.dataset.targetId
expect(chatToolTarget).toBe('tool:call-1')
expect(trajectoryToolTarget).toBe(chatToolTarget)
expect(waterfallToolTarget).toBe(chatToolTarget)
document.querySelector<HTMLButtonElement>('.wf-bar')!.click()
expect(document.querySelector('#wfView')?.classList.contains('active')).toBe(true)
expect(document.querySelector('#trajView')?.classList.contains('active')).toBe(false)
expect(document.querySelector<HTMLElement>('#inspector')?.hidden).toBe(false)
expect(document.querySelector('#inspectorKind')?.textContent).toBe('轮次')
document.querySelector<HTMLButtonElement>('[data-action="jump-traj"]')!.click()
expect(document.querySelector('#trajView')?.classList.contains('active')).toBe(true)
})
})

View File

@@ -0,0 +1,119 @@
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('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' } } },
]
}