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:
@@ -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('反馈')
|
||||
})
|
||||
})
|
||||
|
||||
69
packages/ui/desktop/tests/renderer-regressions.spec.ts
Normal file
69
packages/ui/desktop/tests/renderer-regressions.spec.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
237
packages/ui/desktop/tests/renderer.spec.ts
Normal file
237
packages/ui/desktop/tests/renderer.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
119
packages/ui/desktop/tests/trace-graph.spec.ts
Normal file
119
packages/ui/desktop/tests/trace-graph.spec.ts
Normal 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' } } },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user