feat(ui): add trajectory inspection ledger
This commit is contained in:
@@ -139,7 +139,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
|
||||
expect(turns[0]?.groups[0]?.description).toBe('3s bash×2')
|
||||
})
|
||||
|
||||
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
|
||||
@@ -161,7 +161,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
||||
})
|
||||
|
||||
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
||||
it('keeps usage and a meaningful summary when assistant has no text block', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
|
||||
@@ -172,7 +172,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
text: '仅推理输出', input: 11, output: 22, think: 3,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -235,11 +235,12 @@ describe('run_code sub-dispatch cells', () => {
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]!.groups.flatMap((g) => g.cells)
|
||||
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||
expect(cells.map((c) => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
|
||||
expect(cells[0]?.text).toBe('请求调用 run_code')
|
||||
// Sequential indexes across the interleave; durations from the pair times.
|
||||
expect(cells.map((c) => c.index)).toEqual([1, 2, 3])
|
||||
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
||||
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
|
||||
expect(cells.map((c) => c.index)).toEqual([1, 2, 3, 4])
|
||||
expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
||||
expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
|
||||
})
|
||||
|
||||
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Trajectory ledger selection, details, status, and fold behavior. */
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
|
||||
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const TURNS: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
description: '1.5s bash×2',
|
||||
cells: [
|
||||
{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
text: 'Checking files',
|
||||
outputDetail: 'Checking files',
|
||||
input: 10,
|
||||
output: 20,
|
||||
think: 5,
|
||||
timeSeconds: 1.5,
|
||||
assistantMetrics: {
|
||||
timingRecorded: true,
|
||||
stepStartTime: 1_000,
|
||||
firstTokenTime: 1_500,
|
||||
completedTime: 2_500,
|
||||
usageProvided: true,
|
||||
outputTokens: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
kind: 'tool',
|
||||
text: 'bash · {"command":"pwd"}',
|
||||
inputDetail: '{"command":"pwd"}',
|
||||
timeSeconds: null,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
kind: 'tool',
|
||||
text: 'bash · {"command":"false"}',
|
||||
inputDetail: '{"command":"false"}',
|
||||
outputDetail: 'ToolError: non_zero_exit',
|
||||
result: 'non_zero_exit',
|
||||
isError: true,
|
||||
timeSeconds: 0.2,
|
||||
},
|
||||
],
|
||||
}],
|
||||
}]
|
||||
|
||||
describe('TrajectoryTable', () => {
|
||||
it('shows assistant timing facts after keyboard selection', () => {
|
||||
render(<TrajectoryTable turns={TURNS} collapsed={false} />)
|
||||
fireEvent.keyDown(screen.getByRole('row', { name: /记录 1,ASSISTANT/ }), { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('tab', { name: '计时' }))
|
||||
|
||||
expect(screen.getByText('500 ms')).toBeTruthy()
|
||||
expect(screen.getByText('1.00 s')).toBeTruthy()
|
||||
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps running and failure semantics distinct from record roles', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} collapsed={false} />)
|
||||
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
|
||||
expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('row', { name: /记录 2,TOOL/ }))
|
||||
expect(screen.getByText('进行中')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('row', { name: /记录 3,TOOL/ }))
|
||||
expect(screen.getByText('失败')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '输出' }))
|
||||
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('retains the ledger header and record count when collapsed', () => {
|
||||
render(<TrajectoryTable turns={TURNS} collapsed />)
|
||||
expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy()
|
||||
expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull()
|
||||
expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull()
|
||||
expect(screen.getByText('3 条记录已收起')).toBeTruthy()
|
||||
expect(screen.queryByRole('row', { name: /记录 1,ASSISTANT/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@
|
||||
* registers trajectory/waterfall into a real SlotsService view ring, tabs
|
||||
* switch inside ConversationRoot (renderSlot share driven by the same tab
|
||||
* projection apply uses) without collapsing chat, trajectory renders the
|
||||
* turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
|
||||
* event-ledger chrome (no span stats bar), waterfall keeps in-body stats, and
|
||||
* fiber disposal removes both tabs. Span derivation edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
@@ -169,22 +169,48 @@ describe('plugin registration', () => {
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
const view = mount(b.slots)
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Turn 2')).toBeTruthy()
|
||||
expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
|
||||
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
|
||||
expect(screen.getAllByLabelText('Step 1')).toHaveLength(2)
|
||||
expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy()
|
||||
expect(screen.getByRole('columnheader', { name: '内容' })).toBeTruthy()
|
||||
expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull()
|
||||
expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull()
|
||||
expect(screen.getByRole('toolbar', { name: '轨迹工具栏' }).textContent).toContain('4 条记录')
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起记录' }))
|
||||
expect(screen.getByText('4 条记录已收起')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开记录' }))
|
||||
expect(screen.getByRole('row', { name: /记录 1,USER/ })).toBeTruthy()
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('row', { name: /记录 3,TOOL/ }), { key: 'Enter' })
|
||||
expect(screen.getByRole('complementary', { name: '记录详情' })).toBeTruthy()
|
||||
expect(screen.getByText('记录 #3')).toBeTruthy()
|
||||
expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
|
||||
expect(screen.getByText('完成')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '输入' }))
|
||||
expect(screen.getByText('这条记录没有输入载荷')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '输出' }))
|
||||
expect(screen.getByText('[]')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
|
||||
expect(screen.queryByRole('complementary', { name: '记录详情' })).toBeNull()
|
||||
})
|
||||
|
||||
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
|
||||
Reference in New Issue
Block a user