WorkspaceListState gains archivedSessionIds (ReadonlySet, replaced only on membership change), installed as full snapshots from the list baseline, the unary echo, and the changed frame. Archiving the current session clears the selection into the New Session view state. Test doubles (test-runtime, fake APIs, fixture client) follow the widened IWorkspaces/IApiClient faces.
688 lines
26 KiB
TypeScript
688 lines
26 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* View registration acceptance on the real framework stack: the plugin fiber
|
|
* 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 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 ComponentProps, type FC, type ReactNode } from 'react'
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
|
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
|
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, 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'
|
|
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 { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
|
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
|
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
|
import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
|
|
import {
|
|
TrajectoryView, type TrajectoryViewInjected,
|
|
} from '../src/client/TrajectoryView.tsx'
|
|
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
|
|
|
const SID = 's1' as SessionId
|
|
|
|
// Stub of the conversation package's standard locale seat (this spec mounts
|
|
// its ConversationSession chrome); answers from the zh dictionary and falls
|
|
// back to the key like the real chain.
|
|
const tConversation: ConversationSessionProps['t'] =
|
|
key => (conversationZh as Record<string, string>)[key] ?? key
|
|
afterEach(cleanup)
|
|
// The chat store persists under its declared key; clear so one case's active
|
|
// view cannot rehydrate into the next.
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
})
|
|
|
|
/** 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: [],
|
|
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: [],
|
|
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, pending: [], partial: null,
|
|
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
|
})
|
|
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
|
}
|
|
|
|
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
|
function emptySessions() {
|
|
const store = createSnapshotStore<SessionListState>(
|
|
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
|
return bindSnapshotSelector(store)
|
|
}
|
|
|
|
function emptyWorkspaces() {
|
|
const store = createSnapshotStore<WorkspaceListState>({
|
|
items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
|
recentWorkspaceId: undefined,
|
|
})
|
|
return bindSnapshotSelector(store)
|
|
}
|
|
|
|
/** Standalone view props: the session-scope standard kit the outlet would bake. */
|
|
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
|
return {
|
|
sessionId: SID,
|
|
useSession: fakeSession(nodes).useSession,
|
|
useSessions: emptySessions(),
|
|
useWorkspaces: emptyWorkspaces(),
|
|
useProjection: (() => undefined) as never,
|
|
} as unknown as ConvViewProps
|
|
}
|
|
|
|
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
|
|
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',
|
|
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
|
}, (_p: { renderSlot?: unknown }) => null)
|
|
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
|
slots.register(
|
|
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
|
// '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, loadAllHistory }
|
|
}
|
|
|
|
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
|
function tabsOf(slots: SlotsService): ViewTab[] {
|
|
return slots.entries('conversation.view')
|
|
.map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
|
|
}
|
|
|
|
/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
|
|
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>
|
|
const chat = createChatStore().create()
|
|
// Minimal outlet twin: resolve the ring entry by the `only` filter and
|
|
// render it with the session standard kit (what SlotOutlet does for a
|
|
// list-kind session slot, minus machinery).
|
|
const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => {
|
|
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}
|
|
/>
|
|
)
|
|
}) as unknown as ConversationSessionProps['renderSlot']
|
|
return render(
|
|
<ConversationSession
|
|
sessionId={SID}
|
|
t={tConversation}
|
|
SessionProvider={({ children }) => children(SID)}
|
|
useSession={useSession}
|
|
useSessions={emptySessions()}
|
|
useWorkspaces={emptyWorkspaces()}
|
|
useProjection={(() => undefined)}
|
|
useStore={bindSnapshotSelector(chat)}
|
|
actions={chat.actions}
|
|
renderSlot={renderSlot}
|
|
views={{
|
|
list: () => tabsOf(slots),
|
|
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
|
version: () => slots.getVersion('conversation.view'),
|
|
}}
|
|
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
|
|
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
|
|
bindDraftMirror={() => () => {}}
|
|
open={vi.fn()}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
describe('plugin registration', () => {
|
|
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' },
|
|
])
|
|
})
|
|
|
|
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'])
|
|
})
|
|
})
|
|
|
|
describe('tab switching in ConversationRoot', () => {
|
|
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'])
|
|
|
|
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' }))
|
|
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('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: /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('dragging the overview focuses overlapping records without filtering the ledger', async () => {
|
|
const b = await bench()
|
|
mount(b.slots)
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
|
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('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
|
|
const b = await bench()
|
|
const view = mount(b.slots)
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
|
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: () => ({}),
|
|
})
|
|
const toolSpan = view.container.querySelector<HTMLElement>(
|
|
'[data-timeline-span="tool"]',
|
|
)
|
|
expect(toolSpan).not.toBeNull()
|
|
const recordIndex = toolSpan?.dataset.timelineRecordIndex
|
|
expect(recordIndex).toBeTruthy()
|
|
|
|
fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
|
|
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
|
|
expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
|
|
|
|
fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
|
|
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
|
|
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
|
|
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
|
|
|
|
fireEvent.pointerDown(toolSpan as HTMLElement, {
|
|
button: 0, clientX: 50, pointerId: 2,
|
|
})
|
|
fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
|
|
|
|
const selectedRow = view.container.querySelector<HTMLElement>(
|
|
`tr[data-record-index="${recordIndex}"]`,
|
|
)
|
|
expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
|
|
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
|
|
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
|
|
})
|
|
|
|
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('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[]
|
|
const longTurns = [{
|
|
turn: 1,
|
|
groups: [{
|
|
title: 'Step 1',
|
|
cells: Array.from({ length: 10 }, (_, index) => ({
|
|
index,
|
|
kind: 'message' as const,
|
|
text: `record ${index}`,
|
|
timeSeconds: 1,
|
|
})),
|
|
}],
|
|
}] satisfies readonly TrajectoryTurnModel[]
|
|
|
|
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
|
|
const onRangeChange = vi.fn()
|
|
const view = render(
|
|
<TrajectoryTimeline
|
|
turns={longTurns}
|
|
mode="sequence"
|
|
range={null}
|
|
onRangeChange={onRangeChange}
|
|
/>,
|
|
)
|
|
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.wheel(plot, { clientX: 50, deltaY: -1_000 })
|
|
|
|
view.rerender(
|
|
<TrajectoryTimeline
|
|
turns={longTurns}
|
|
mode="sequence"
|
|
range={null}
|
|
selectedIndex={1}
|
|
onRangeChange={onRangeChange}
|
|
/>,
|
|
)
|
|
await vi.waitFor(() => {
|
|
const domain = view.container.querySelector<HTMLElement>(
|
|
'[data-timeline-domain]',
|
|
)
|
|
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
|
|
})
|
|
|
|
view.rerender(
|
|
<TrajectoryTimeline
|
|
turns={longTurns}
|
|
mode="sequence"
|
|
range={null}
|
|
selectedIndex={8}
|
|
onRangeChange={onRangeChange}
|
|
/>,
|
|
)
|
|
await vi.waitFor(() => {
|
|
const domain = view.container.querySelector<HTMLElement>(
|
|
'[data-timeline-domain]',
|
|
)
|
|
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
|
|
})
|
|
})
|
|
|
|
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
|
|
const onRangeChange = vi.fn()
|
|
render(
|
|
<TrajectoryTimeline
|
|
turns={longTurns}
|
|
mode="sequence"
|
|
range={null}
|
|
onRangeChange={onRangeChange}
|
|
/>,
|
|
)
|
|
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.wheel(plot, { clientX: 50, deltaY: -1_000 })
|
|
fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
|
|
for (let index = 0; index < 24; index++) {
|
|
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
|
|
}
|
|
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
|
|
|
|
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
|
|
| { start: number; end: number }
|
|
| undefined
|
|
expect(selectedRange).toBeDefined()
|
|
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
|
|
})
|
|
|
|
it('uses equal-width operation slots and stable semantic lanes', () => {
|
|
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
|
start: 0,
|
|
end: 3,
|
|
spans: [
|
|
{
|
|
index: 1, isError: false, kind: 'message', label: 'assistant',
|
|
lane: 1, start: 0, end: 1,
|
|
},
|
|
{
|
|
index: 2, isError: false, kind: 'tool', label: 'bash',
|
|
lane: 2, start: 1, end: 2,
|
|
},
|
|
{
|
|
index: 3, isError: false, kind: 'user', label: 'unknown',
|
|
lane: 0, start: 2, end: 3,
|
|
},
|
|
],
|
|
turnBoundaries: [{ turn: 1, time: 0 }],
|
|
})
|
|
})
|
|
|
|
it('marks error records directly on timeline spans', () => {
|
|
const errorTurns = [{
|
|
turn: 1,
|
|
groups: [{
|
|
title: 'Step 1',
|
|
cells: [{
|
|
index: 1,
|
|
kind: 'tool' as const,
|
|
text: 'failed tool',
|
|
timeSeconds: 0.1,
|
|
isError: true,
|
|
}],
|
|
}],
|
|
}] satisfies readonly TrajectoryTurnModel[]
|
|
const view = render(
|
|
<TrajectoryTimeline
|
|
turns={errorTurns}
|
|
mode="sequence"
|
|
range={null}
|
|
onRangeChange={() => {}}
|
|
/>,
|
|
)
|
|
|
|
expect(view.container.querySelector(
|
|
'[data-timeline-span="tool"][data-error="true"]',
|
|
)).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('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('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()
|
|
})
|
|
})
|
|
|
|
describe('node half', () => {
|
|
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
|
expect(() => { nodeApply() }).not.toThrow()
|
|
})
|
|
})
|