|
|
@@ -1,11 +1,11 @@
|
|
|
// @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
|
|
|
- * event-ledger 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 filtering edge cases ride along.
|
|
|
*/
|
|
|
import { Context } from 'cordis'
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
@@ -23,10 +23,13 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa
|
|
|
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 type { TrajectoryTurnModel } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/layout.ts'
|
|
|
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 {
|
|
|
+ deriveTrajectoryTimeline,
|
|
|
+ filterTrajectoryTimelineRange,
|
|
|
+ formatTimelineOffset,
|
|
|
+} from '@deepseek-ai/dsh-client-ui-trajectory/src/client/timeline.ts'
|
|
|
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
|
|
|
|
|
const SID = 's1' as SessionId
|
|
|
@@ -42,12 +45,18 @@ 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 fakeSession(nodes: ConversationSnapshot['nodes']) {
|
|
|
@@ -167,16 +176,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'])
|
|
|
@@ -184,17 +192,18 @@ describe('plugin registration', () => {
|
|
|
})
|
|
|
|
|
|
describe('tab switching in ConversationRoot', () => {
|
|
|
- it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => {
|
|
|
+ it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => {
|
|
|
const b = await bench()
|
|
|
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(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' }))
|
|
|
@@ -220,47 +229,72 @@ describe('tab switching in ConversationRoot', () => {
|
|
|
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('waterfall renders bars and switching back to chat does not collapse it', async () => {
|
|
|
+ it('dragging the overview focuses overlapping records and clear restores the ledger', 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' }))
|
|
|
+ const plot = screen.getByLabelText('Timeline overview; drag horizontally to filter 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 })
|
|
|
|
|
|
- fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
|
|
- expect(screen.getByTestId('chat-body')).toBeTruthy()
|
|
|
+ expect(screen.queryByRole('row', { name: /USER/ })).toBeNull()
|
|
|
+ expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy()
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
|
|
|
+ expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
|
|
})
|
|
|
|
|
|
- it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
|
|
+ it('empty window keeps the toolbar and reports no timing data', async () => {
|
|
|
const b = await bench()
|
|
|
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 real start/duration timing and stable semantic lanes', () => {
|
|
|
+ expect(deriveTrajectoryTimeline(turns)).toEqual({
|
|
|
+ start: 1_000,
|
|
|
+ end: 3_000,
|
|
|
+ spans: [
|
|
|
+ {
|
|
|
+ index: 1, kind: 'message', label: 'assistant', lane: 1, start: 1_000, end: 2_000,
|
|
|
+ },
|
|
|
+ { index: 2, kind: 'tool', label: 'bash', lane: 2, start: 2_000, end: 3_000 },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ expect(formatTimelineOffset(999)).toBe('999 ms')
|
|
|
+ expect(formatTimelineOffset(1_500)).toBe('1.5 s')
|
|
|
})
|
|
|
|
|
|
- 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()
|
|
|
+ it('filters inclusively and drops records without known timing', () => {
|
|
|
+ const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 })
|
|
|
+ expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2])
|
|
|
+ expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
|
|
+ expect(deriveTrajectoryTimeline([])).toBeNull()
|
|
|
render(createElement(
|
|
|
TrajectoryView,
|
|
|
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
|
|
|
@@ -343,135 +377,8 @@ describe('TrajectoryView branches', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
-describe('WaterfallView standalone branches', () => {
|
|
|
- it('empty window renders the placeholder copy', () => {
|
|
|
- render(createElement(WaterfallView as FC<ConvViewProps>,
|
|
|
- standaloneProps([])))
|
|
|
- expect(screen.getByText('No timing data')).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- 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()
|
|
|
- })
|
|
|
-})
|
|
|
-
|
|
|
describe('node half', () => {
|
|
|
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
|
|
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(),
|
|
|
- } 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.80 s"]')).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(),
|
|
|
- } 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')
|
|
|
- })
|
|
|
-})
|