Merge remote-tracking branch 'origin/fix/human-transcript-projection' into fix/web-transcript-projection
# Conflicts: # apps/web/tests/snapshots/seeded-history/ui.expected.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/fold-adapter.ts # packages/client/runtime/tests/fold-adapter.spec.ts # packages/client/ui-trajectory/src/client/layout.ts # packages/client/ui-trajectory/src/client/spans.ts # packages/compact/compact/README.i18n.yaml
This commit is contained in:
@@ -16,11 +16,11 @@ afterEach(cleanup)
|
||||
describe('formatElapsedSeconds', () => {
|
||||
it('formats known durations and uses an em dash when absent', () => {
|
||||
expect(formatElapsedSeconds(null)).toBe('—')
|
||||
expect(formatElapsedSeconds(235)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.0)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.2)).toBe('+235.2s')
|
||||
expect(formatElapsedSeconds(235.25)).toBe('+235.3s')
|
||||
expect(formatElapsedSeconds(0)).toBe('+0s')
|
||||
expect(formatElapsedSeconds(235)).toBe('235 s')
|
||||
expect(formatElapsedSeconds(235.0)).toBe('235 s')
|
||||
expect(formatElapsedSeconds(235.2)).toBe('235.2 s')
|
||||
expect(formatElapsedSeconds(235.25)).toBe('235.3 s')
|
||||
expect(formatElapsedSeconds(0)).toBe('0 s')
|
||||
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -38,7 +38,7 @@ describe('TrajectoryCell', () => {
|
||||
expect(screen.getByText('#6')).toBeTruthy()
|
||||
expect(screen.getByText('Tool')).toBeTruthy()
|
||||
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
|
||||
expect(screen.getByText('+5s')).toBeTruthy()
|
||||
expect(screen.getByText('5 s')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Message rows expose Input / Output / Think metric columns before time', () => {
|
||||
@@ -57,11 +57,11 @@ describe('TrajectoryCell', () => {
|
||||
expect(screen.getByText('136')).toBeTruthy()
|
||||
expect(screen.getByText('381')).toBeTruthy()
|
||||
expect(screen.getByText('155')).toBeTruthy()
|
||||
expect(screen.getByText('+235.2s')).toBeTruthy()
|
||||
expect(screen.getByText('235.2 s')).toBeTruthy()
|
||||
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
|
||||
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
|
||||
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
|
||||
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
|
||||
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235.2 s'))
|
||||
})
|
||||
|
||||
it('selected marks the row for the brand-primary inset ring', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Real tsdown artifact shape: lib/client.js hands off through
|
||||
* window.__ModuleLoader__.load, resolves externals through the injected
|
||||
* require, returns the export surface (apply + inject), and a mounted apply
|
||||
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
|
||||
* registers the view tab into a real SlotsService ring. Skips when dist/ is
|
||||
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -41,12 +41,13 @@ describe('tsdown client artifact', () => {
|
||||
// Same execution form the loader uses (inline script eval, window scope) —
|
||||
// the implied-eval ban targets accidental string execution, not this
|
||||
// deliberate bundle-execution fixture.
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
|
||||
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
|
||||
new Function(code!)()
|
||||
expect(handoff).toBeDefined()
|
||||
const modules = new Map<string, unknown>([
|
||||
['react', await import('react')],
|
||||
['react/jsx-runtime', await import('react/jsx-runtime')],
|
||||
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
|
||||
])
|
||||
const surface = handoff!.factory((spec) => {
|
||||
if (!modules.has(spec)) throw new Error(`unexpected require: ${spec}`)
|
||||
@@ -59,10 +60,10 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots', 'conversation'])
|
||||
expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||
const { surface } = await loadArtifact()
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
@@ -71,13 +72,14 @@ describe('tsdown client artifact', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin injects 'conversation' as an ordering edge (the declaring
|
||||
// plugin provides it after declaring the ring); the bench declares the
|
||||
// ring itself, so a stub satisfies the wait.
|
||||
// The plugin injects 'conversation' as an ordering edge and
|
||||
// 'sessionHistory' for its per-session history callback; this bench
|
||||
// supplies both.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessionHistory', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.view')).toHaveLength(0)
|
||||
})
|
||||
|
||||
83
packages/client/ui-trajectory/tests/context-branches.spec.ts
Normal file
83
packages/client/ui-trajectory/tests/context-branches.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ConversationContext, ConversationNode, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches,
|
||||
trajectoryBranchContainsRequest,
|
||||
} from '../src/client/context-branches.ts'
|
||||
|
||||
const checkpoint = {
|
||||
kind: 'context',
|
||||
seq: 100,
|
||||
time: 100,
|
||||
content: [],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
} as ConversationNode
|
||||
|
||||
const abandoned = {
|
||||
kind: 'assistant',
|
||||
seq: 20,
|
||||
time: 20,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'abandoned' }],
|
||||
} as ConversationNode
|
||||
|
||||
const current = {
|
||||
kind: 'user',
|
||||
seq: 110,
|
||||
time: 110,
|
||||
content: [{ type: 'text', text: 'rewound' }],
|
||||
source: { kind: 'plugin', plugin: 'rewind' },
|
||||
} as ConversationNode
|
||||
|
||||
function request(
|
||||
purpose: RequestView['purpose'],
|
||||
startSeq: number,
|
||||
resultSeq?: number,
|
||||
replacementSeq?: number,
|
||||
): RequestView {
|
||||
return {
|
||||
purpose,
|
||||
startSeq,
|
||||
turn: 1,
|
||||
step: purpose === 'assistant' ? 1 : 0,
|
||||
startedAt: startSeq,
|
||||
completedAt: startSeq + 1,
|
||||
status: 'complete',
|
||||
...(resultSeq === undefined ? {} : { resultSeq }),
|
||||
...(replacementSeq === undefined ? {} : { replacementSeq }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('trajectory context branches', () => {
|
||||
it('inherits nodes and requests by retained surface position rather than seq cutoff', () => {
|
||||
const contexts: ConversationContext[] = [
|
||||
{ id: 0, nodes: [checkpoint, abandoned] },
|
||||
{
|
||||
id: 1,
|
||||
parentId: 0,
|
||||
origin: 'rewind',
|
||||
originSeq: 110,
|
||||
nodes: [checkpoint, current],
|
||||
},
|
||||
]
|
||||
const branches = deriveTrajectoryContextBranches(contexts)
|
||||
const successor = branches[1]!
|
||||
|
||||
expect(successor.nodes.map(node => node.seq)).toEqual([110])
|
||||
expect(trajectoryBranchContainsRequest(
|
||||
successor,
|
||||
request('assistant', 10, 20),
|
||||
)).toBe(false)
|
||||
expect(trajectoryBranchContainsRequest(
|
||||
successor,
|
||||
request('compaction', 90, 95, 100),
|
||||
)).toBe(true)
|
||||
expect(trajectoryBranchContainsRequest(
|
||||
successor,
|
||||
request('assistant', 111),
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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('3 s 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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,8 +192,8 @@ describe('deriveTrajectoryLayout', () => {
|
||||
kind: 'context', seq: 4, time: 9_000,
|
||||
content: [{ type: 'text', text: 'extra' }], source: null,
|
||||
},
|
||||
// A landed compaction renders no cell either, but is still a real log
|
||||
// position, so it moves the cursor the same way a context row does.
|
||||
// A landed compaction renders no cell, but is still a real log position,
|
||||
// so it moves the cursor after the visible context row.
|
||||
{ kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' },
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
|
||||
@@ -205,8 +205,25 @@ describe('deriveTrajectoryLayout', () => {
|
||||
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
|
||||
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
|
||||
expect(message?.timeSeconds).toBe(0.5)
|
||||
// Neither the context row nor the marker contributed a cell.
|
||||
expect(cells).toHaveLength(3)
|
||||
// Context remains inspectable in trajectory; the Chat marker is not duplicated.
|
||||
expect(cells.map(cell => cell.kind)).toEqual(['user', 'message', 'tool', 'context', 'message'])
|
||||
})
|
||||
|
||||
it('uses the recorded step start for assistant duration when timing exists', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 4_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
|
||||
})
|
||||
const message = turns[0]?.groups.flatMap(group => group.cells)
|
||||
.find(cell => cell.kind === 'message')
|
||||
expect(message).toMatchObject({ startedAt: 3_000, timeSeconds: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -239,11 +256,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('Tool call only')
|
||||
// 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', () => {
|
||||
|
||||
190
packages/client/ui-trajectory/tests/table.spec.tsx
Normal file
190
packages/client/ui-trajectory/tests/table.spec.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Trajectory ledger selection, details, status, and fold behavior. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } 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,
|
||||
},
|
||||
],
|
||||
}],
|
||||
}]
|
||||
|
||||
const FOLD_PROPS = {
|
||||
collapsedTurns: new Set<number>(),
|
||||
onToggleTurn: () => {},
|
||||
collapsedAssistants: new Set<number>(),
|
||||
onToggleAssistant: () => {},
|
||||
}
|
||||
|
||||
describe('TrajectoryTable', () => {
|
||||
it('shows assistant timing facts after keyboard selection', () => {
|
||||
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
|
||||
|
||||
expect(screen.getByText('500 ms')).toBeTruthy()
|
||||
expect(screen.getByText('1.00 s')).toBeTruthy()
|
||||
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('breaks output tokens into labeled reasoning and content rows', () => {
|
||||
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
|
||||
|
||||
expect(screen.getByText('Tokens')).toBeTruthy()
|
||||
expect(screen.getByText('20 tok')).toBeTruthy()
|
||||
expect(screen.getByText('Reasoning')).toBeTruthy()
|
||||
expect(screen.getByText('5 tok')).toBeTruthy()
|
||||
expect(screen.getByText('Content')).toBeTruthy()
|
||||
expect(screen.getByText('15 tok')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps raw HTML tags in a Markdown-derived context preview', () => {
|
||||
const html = [
|
||||
'<background-task-complete id="trajectory-ui-watch">',
|
||||
'Command: pnpm test',
|
||||
'Exit code: 0',
|
||||
'</background-task-complete>',
|
||||
].join('\n')
|
||||
const turns: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Message',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'context',
|
||||
text: '',
|
||||
inputDetail: html,
|
||||
timeSeconds: 0,
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
expect(screen.getByText(
|
||||
'<background-task-complete id="trajectory-ui-watch"> Command: pnpm test Exit code: 0 </background-task-complete>',
|
||||
)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears the selected row when ledger whitespace is clicked', () => {
|
||||
const onClearSelection = vi.fn()
|
||||
render(
|
||||
<TrajectoryTable
|
||||
turns={TURNS}
|
||||
{...FOLD_PROPS}
|
||||
onClearSelection={onClearSelection}
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('row', { name: /ASSISTANT/ })
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
|
||||
|
||||
const tablePane = screen.getByRole('table').parentElement
|
||||
expect(tablePane).not.toBeNull()
|
||||
fireEvent.click(tablePane as HTMLElement)
|
||||
|
||||
expect(row.getAttribute('aria-selected')).toBe('false')
|
||||
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
||||
expect(onClearSelection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps running and failure semantics distinct from record roles', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
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: /TOOL, bash \{"command":"pwd"\}/ }))
|
||||
expect(screen.getByText('Pending')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
|
||||
expect(screen.getByText('Failed')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
|
||||
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a single-text JSON tool result as a JSON tree', () => {
|
||||
const turns: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'tool',
|
||||
text: 'read {"path":"result.json"}',
|
||||
outputDetail: '{"value":1,"nested":{"ok":true}}',
|
||||
outputBlocks: [{
|
||||
type: 'text',
|
||||
content: '{"value":1,"nested":{"ok":true}}',
|
||||
}],
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
|
||||
|
||||
expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy()
|
||||
expect(screen.getByText('value:')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the first row and a compact summary when a turn is collapsed', () => {
|
||||
render(
|
||||
<TrajectoryTable
|
||||
turns={TURNS}
|
||||
{...FOLD_PROPS}
|
||||
collapsedTurns={new Set([1])}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('columnheader')).toBeNull()
|
||||
expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
|
||||
expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,31 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* View registration acceptance on the real framework stack: the plugin fiber
|
||||
* registers trajectory/waterfall into a real SlotsService view ring, tabs
|
||||
* registers Trajectory 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
|
||||
* fiber disposal removes both tabs. Span derivation edge cases ride along.
|
||||
* event ledger with its timing overview, and fiber disposal removes the tab.
|
||||
* Timeline projection and inclusive focus edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement, type FC, type ReactNode } from 'react'
|
||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
|
||||
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
||||
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
|
||||
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
|
||||
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
||||
import {
|
||||
TrajectoryView, type TrajectoryViewInjected,
|
||||
} from '../src/client/TrajectoryView.tsx'
|
||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
afterEach(cleanup)
|
||||
@@ -40,17 +43,56 @@ beforeEach(() => {
|
||||
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
|
||||
const NODES = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [],
|
||||
timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 },
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
|
||||
timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
function historySnapshot(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
inspection: Partial<SessionHistoryInspection> = {},
|
||||
): SessionHistorySnapshot {
|
||||
return {
|
||||
state: 'ready',
|
||||
error: null,
|
||||
hasMore: false,
|
||||
inspection: {
|
||||
eventNodes: nodes,
|
||||
contexts: [{ id: 0, nodes }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [],
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
...inspection,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneHistory(
|
||||
snapshot: SessionHistorySnapshot,
|
||||
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
|
||||
const store = createSnapshotStore(snapshot)
|
||||
return {
|
||||
useHistory: bindSnapshotSelector(store),
|
||||
loadAllHistory: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore({
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
nodes, pending: [], partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
@@ -82,9 +124,17 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
}
|
||||
|
||||
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
|
||||
async function bench() {
|
||||
async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
||||
const historyStore = createSnapshotStore(snapshot)
|
||||
const history: SessionHistoryFace = {
|
||||
sessionId: SID,
|
||||
getSnapshot: () => historyStore.getSnapshot(),
|
||||
subscribe: listener => historyStore.subscribe(listener),
|
||||
loadAll: loadAllHistory,
|
||||
}
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
@@ -96,9 +146,10 @@ async function bench() {
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessionHistory', { source: () => history })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber }
|
||||
return { ctx, slots, fiber, loadAllHistory }
|
||||
}
|
||||
|
||||
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
||||
@@ -111,6 +162,8 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
pending: [],
|
||||
openState: 'open' as const, hasMore: true, loadingOlder: false,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
@@ -122,8 +175,21 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
|
||||
if (entry === undefined) return null
|
||||
const View = entry.component as FC<ConvViewProps>
|
||||
const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
|
||||
const injected = injectEntry === undefined
|
||||
? {}
|
||||
: injectEntry(SID)
|
||||
const injectedProps = 'hooks' in injected
|
||||
? {
|
||||
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
||||
useHistory: bindSnapshotSelector(
|
||||
(injected as TrajectoryViewInjected).hooks.history,
|
||||
),
|
||||
}
|
||||
: injected
|
||||
return (
|
||||
<View
|
||||
{...injectedProps}
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||
key={key}
|
||||
/>
|
||||
@@ -154,16 +220,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
}
|
||||
|
||||
describe('plugin registration', () => {
|
||||
it('registers trajectory and waterfall after chat on the ring', async () => {
|
||||
it('registers trajectory after chat on the ring', async () => {
|
||||
const b = await bench()
|
||||
expect(tabsOf(b.slots)).toEqual([
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
{ id: 'waterfall', label: 'Waterfall' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
||||
it('fiber disposal removes the tab and leaves chat standing', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.dispose()
|
||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||
@@ -171,80 +236,275 @@ 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 two 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'])
|
||||
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
|
||||
|
||||
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.queryByRole('columnheader')).toBeNull()
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
|
||||
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
||||
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.loadAllHistory).toHaveBeenCalledOnce()
|
||||
})
|
||||
const signal = b.loadAllHistory.mock.calls[0]?.[0]
|
||||
expect(signal?.aborted).toBe(false)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
|
||||
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: 'Waterfall' }))
|
||||
expect(screen.getByTitle('2 nodes')).toBeTruthy()
|
||||
expect(screen.getByTitle('1 tool calls')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' })
|
||||
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
|
||||
expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
|
||||
expect(screen.getByText('Completed')).toBeTruthy()
|
||||
expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close details' }))
|
||||
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
||||
})
|
||||
|
||||
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
||||
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots, [])
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 })
|
||||
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
|
||||
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
|
||||
|
||||
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||
.toBe('outside')
|
||||
|
||||
const tablePane = screen.getByRole('table').parentElement
|
||||
expect(tablePane).not.toBeNull()
|
||||
fireEvent.click(tablePane as HTMLElement)
|
||||
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||
.toBeNull()
|
||||
|
||||
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 })
|
||||
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 })
|
||||
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 })
|
||||
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||
.toBe('outside')
|
||||
fireEvent.contextMenu(plot)
|
||||
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('empty window keeps the toolbar and reports no timing data', async () => {
|
||||
const b = await bench(historySnapshot([]))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByText('No timing data')).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('span derivation', () => {
|
||||
it('attributes prologue to turn 0 and follows steering turn tags', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1 },
|
||||
{ kind: 'steering', seq: 2, turn: 5 },
|
||||
{ kind: 'user', seq: 3 },
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const spans = deriveSpans(nodes)
|
||||
expect(spans).toEqual([
|
||||
{ turn: 0, steps: 0, calls: 0, nodes: 1 },
|
||||
{ turn: 5, steps: 0, calls: 0, nodes: 2 },
|
||||
])
|
||||
expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 })
|
||||
describe('timeline projection', () => {
|
||||
const turns = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 },
|
||||
{ index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 },
|
||||
{ index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 },
|
||||
],
|
||||
}],
|
||||
}] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
it('uses equal-width operation slots and stable semantic lanes', () => {
|
||||
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
||||
start: 0,
|
||||
end: 3,
|
||||
spans: [
|
||||
{
|
||||
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
|
||||
},
|
||||
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
|
||||
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
|
||||
],
|
||||
turnBoundaries: [{ turn: 1, time: 0 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
|
||||
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
|
||||
expect(container.firstChild).toBeNull()
|
||||
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
it('ignores durations and idle gaps while retaining turn boundaries', () => {
|
||||
const separatedTurns = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
|
||||
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
|
||||
start: 0,
|
||||
end: 3,
|
||||
spans: [
|
||||
{ index: 1, start: 0, end: 1 },
|
||||
{ index: 2, start: 1, end: 2 },
|
||||
{ index: 3, start: 2, end: 3 },
|
||||
],
|
||||
turnBoundaries: [
|
||||
{ turn: 1, time: 0 },
|
||||
{ turn: 2, time: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
||||
expect(deriveTrajectoryTimeline([])).toBeNull()
|
||||
render(createElement(
|
||||
TrajectoryView,
|
||||
{
|
||||
...standaloneProps([]),
|
||||
...standaloneHistory(historySnapshot([])),
|
||||
},
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WaterfallView standalone branches', () => {
|
||||
it('empty window renders the placeholder copy', () => {
|
||||
render(createElement(WaterfallView as FC<ConvViewProps>,
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
|
||||
describe('TrajectoryView branches', () => {
|
||||
it('renders only the selected rewind branch while retaining session-global requests', () => {
|
||||
const retained = {
|
||||
kind: 'user',
|
||||
seq: 1,
|
||||
time: 1_000,
|
||||
content: [{ type: 'text', text: 'retained user' }],
|
||||
source: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const abandoned = {
|
||||
kind: 'assistant',
|
||||
seq: 3,
|
||||
time: 3_000,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'abandoned response' }],
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const current = {
|
||||
kind: 'assistant',
|
||||
seq: 5,
|
||||
time: 5_000,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'current response' }],
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const request = (startSeq: number, turn: number): RequestView => ({
|
||||
purpose: 'assistant',
|
||||
startSeq,
|
||||
turn,
|
||||
step: 1,
|
||||
startedAt: startSeq * 1_000,
|
||||
completedAt: startSeq * 1_000 + 100,
|
||||
status: 'complete',
|
||||
})
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained, abandoned, current],
|
||||
{
|
||||
eventNodes: [retained, abandoned, current],
|
||||
contexts: [
|
||||
{ id: 0, nodes: [retained, abandoned] },
|
||||
{
|
||||
id: 1,
|
||||
parentId: 0,
|
||||
origin: 'rewind' as const,
|
||||
originSeq: 4,
|
||||
nodes: [retained, current],
|
||||
},
|
||||
],
|
||||
requests: [request(2, 1), request(4, 2)],
|
||||
callSchemas: new Map(),
|
||||
},
|
||||
))
|
||||
|
||||
const view = render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('abandoned response')).toBeNull()
|
||||
expect(screen.getByText('current response')).toBeTruthy()
|
||||
expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy()
|
||||
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a turn without tool calls renders the node bar only', () => {
|
||||
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
|
||||
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
|
||||
expect(screen.getByTitle('1 nodes')).toBeTruthy()
|
||||
expect(screen.queryByTitle(/tool calls/)).toBeNull()
|
||||
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
|
||||
const retained = {
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'stop the task' }], source: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const interruptedAssistant = {
|
||||
kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'partial response retained' }],
|
||||
interrupted: true,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const interruptedTool = {
|
||||
kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call',
|
||||
call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900,
|
||||
content: [], isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: null, resultView: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained],
|
||||
{
|
||||
eventNodes: [retained],
|
||||
contexts: [{ id: 0, nodes: [retained] }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [interruptedAssistant, interruptedTool],
|
||||
},
|
||||
))
|
||||
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('partial response retained')).toBeTruthy()
|
||||
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,117 +513,3 @@ describe('node half', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
const dispatchNodes = [
|
||||
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
it('scales settled lanes into the dispatch window with real durations', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const turn3 = lanes.get(3)
|
||||
expect(turn3).toHaveLength(2)
|
||||
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
|
||||
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
|
||||
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
|
||||
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
|
||||
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
|
||||
})
|
||||
|
||||
it('a running lane extends to the window end with a null duration', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
|
||||
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
|
||||
// Extends from its start to the window end.
|
||||
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
|
||||
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
|
||||
})
|
||||
|
||||
it('waterfall renders sub-span lanes under the owning turn row', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const lane = view.container.querySelector('[data-subspan]')
|
||||
expect(lane).not.toBeNull()
|
||||
expect(lane!.textContent).toContain('bash')
|
||||
expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
|
||||
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('waterfall labels a settle-only lane as duration unknown', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const bar = view.container.querySelector('[data-timing="unknown"]')
|
||||
expect(bar).not.toBeNull()
|
||||
expect(bar!.getAttribute('title')).toContain('duration unknown')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user