| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525 |
- // @vitest-environment jsdom
- /**
- * View registration acceptance on the real framework stack: the plugin fiber
- * registers Trajectory into a real SlotRegistry 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 type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
- import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
- import { bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
- import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
- import {
- EMPTY_CONVERSATION_SNAPSHOT, UiConversation,
- } from '@deepseek-ai/dsh-client-ui-conversation/client'
- import type {
- ConversationBinding, ConversationSnapshot, ConversationViewSnapshotMap, ConvViewProps,
- InputActions, InputState, RequestView, ViewTab,
- } from '@deepseek-ai/dsh-client-ui-conversation/client'
- import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client'
- import type {
- ChatSnapshot, LegacyConversationSlice,
- } from '@deepseek-ai/dsh-client-ui-chat/client'
- import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
- import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
- import type {
- SessionBinding, SessionListState, SessionProjectionMap, SessionSnapshot, UseProjection,
- } from '@deepseek-ai/dsh-api-session-controller/client'
- import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
- import type { SessionId } from '@deepseek-ai/dsh-session/types'
- import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
- import {
- ConversationSession, ConversationSessionHeader,
- type ConversationSessionHeaderProps, type ConversationSessionProps,
- } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
- import { createConversationStore } 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 as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
- 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 as LocalizedTrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
- import {
- TrajectoryView, type TrajectoryViewInjected,
- } from '../src/client/TrajectoryView.tsx'
- import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
- import { EMPTY_TRAJECTORY_SNAPSHOT } from '../src/client/trajectory-snapshot-builder.ts'
- import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
- import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
- import { t as tTrajectory, tZh } from './locale.client.ts'
- // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
- const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
- const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
- function TrajectoryTimeline(
- props: Omit<ComponentProps<typeof LocalizedTrajectoryTimeline>, 't'>,
- ) {
- return <LocalizedTrajectoryTimeline {...props} t={tTrajectory} />
- }
- const SID = 's1' as SessionId
- const tConversation: ConversationSessionHeaderProps['t'] =
- key => (conversationZh as Record<string, string>)[key] ?? key
- const runtimes: SlotTestRuntime[] = []
- afterEach(async () => {
- cleanup()
- vi.restoreAllMocks()
- Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
- for (const runtime of runtimes.splice(0)) await runtime.dispose()
- })
- // The Conversation 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: LegacyConversationSlice['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, subCalls: [],
- },
- {
- kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
- timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
- },
- ]
- function historySnapshot(
- nodes: LegacyConversationSlice['nodes'],
- inspection: Partial<TrajectorySnapshot> = {},
- ): TrajectorySnapshot {
- return {
- eventNodes: nodes,
- eventLocations: new Map(),
- requests: [],
- callSchemas: new Map(),
- partial: null,
- runningCalls: [],
- ...inspection,
- }
- }
- function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapshot {
- return {
- sessionId: SID,
- pendingSubmissions: [],
- running: false,
- subagent: null,
- removed: false,
- openState: 'open',
- openError: null,
- hasMore: false,
- loadingOlder: false,
- promptError: null,
- blank: nodes.length === 0,
- lastAgentError: null,
- promptAttempted: nodes.length > 0,
- awaitingFirstTurn: false,
- }
- }
- function conversationSnapshot(
- trajectory: TrajectorySnapshot,
- ): ConversationSnapshot {
- return {
- views: EMPTY_CONVERSATION_SNAPSHOT.views,
- activeTargets: trajectory.eventNodes.length === 0
- ? new Set()
- : new Set(['trajectory']),
- }
- }
- function standaloneHistory(
- snapshot: TrajectorySnapshot,
- ): Pick<
- ComponentProps<typeof TrajectoryView>,
- 'useSession' | 'useTrajectory' | 'loadOlder'
- > {
- const session = createSnapshotStore(sessionSnapshot(snapshot.eventNodes))
- const trajectory = createSnapshotStore(snapshot)
- return {
- useSession: bindSnapshotSelector(session),
- useTrajectory: bindSnapshotSelector(trajectory),
- loadOlder: () => Promise.resolve(false),
- }
- }
- function standaloneDuration(): Pick<
- ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
- > {
- const duration = createSnapshotStore(false)
- return {
- useDuration: bindSnapshotSelector(duration),
- setActualDuration: (value) => { duration.set(value) },
- }
- }
- /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
- function emptySessions() {
- const store = createSnapshotStore<SessionListState>(
- { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
- return bindSnapshotSelector(store)
- }
- function emptyWorkspaces() {
- const store = createSnapshotStore<WorkspaceSnapshot>({
- items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
- })
- return bindSnapshotSelector(store)
- }
- function emptyProjection<Key extends Extract<keyof SessionProjectionMap, string>>(
- key: Key,
- ): SessionProjectionMap[Key] | undefined
- function emptyProjection<Key extends Extract<keyof SessionProjectionMap, string>, Selected>(
- key: Key,
- selector: (value: SessionProjectionMap[Key] | undefined) => Selected,
- eq?: (left: Selected, right: Selected) => boolean,
- ): Selected
- function emptyProjection<Key extends Extract<keyof SessionProjectionMap, string>, Selected>(
- _key: Key,
- selector?: (value: SessionProjectionMap[Key] | undefined) => Selected,
- ): SessionProjectionMap[Key] | Selected | undefined {
- return selector === undefined ? undefined : selector(undefined)
- }
- const useProjection: UseProjection = emptyProjection
- type StandaloneBaseProps = Omit<
- ComponentProps<typeof TrajectoryView>,
- 'useSession' | 'useTrajectory' | 'useDuration' | 'loadOlder' | 'setActualDuration'
- >
- /** Standalone view props: the session-scope standard kit the outlet would bake. */
- function standaloneProps(
- nodes: LegacyConversationSlice['nodes'],
- ): StandaloneBaseProps {
- const trajectory = historySnapshot(nodes)
- const input = createSnapshotStore<InputState>({
- draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
- })
- const inputActions: InputActions = {
- setDraft: () => {},
- addAttachments: () => false,
- removeAttachment: () => {},
- pruneAttachments: () => {},
- submit: () => {},
- }
- return {
- sessionId: SID,
- useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)),
- useSessions: emptySessions(),
- usePanelInfo, useResource,
- useSessionStatus: bindSnapshotSelector(
- createSnapshotStore<SessionStatusSnapshot>(new Map()),
- ),
- useSessionRetainInfo: () => undefined,
- useWorkspaces: emptyWorkspaces(),
- useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))),
- useInput: bindSnapshotSelector(input),
- inputActions,
- useProjection,
- viewRequest: null,
- openView: () => {},
- completeViewRequest: () => {},
- // Image seats the outlet would bake: standalone renders omit the gallery.
- renderSlot: () => null,
- SessionProvider: ({ children }) => <>{children}</>,
- loadImage: () => Promise.reject(new Error('standalone views load no images')),
- // The locale seat the outlet would inject for the declared namespace.
- t: tZh,
- }
- }
- type ConversationTargetSources = {
- [Target in Extract<keyof ConversationViewSnapshotMap, string>]:
- ObservableSnapshot<ConversationViewSnapshotMap[Target] | undefined>
- }
- /** Real-stack bench: root Context + real SlotRegistry ring + the plugin fiber. */
- async function bench(snapshot = historySnapshot(NODES)) {
- const runtime = await SlotTestRuntime.create()
- runtimes.push(runtime)
- const ctx = runtime.ctx
- const slots = runtime.slots
- const loadOlder = vi.fn(() => Promise.resolve())
- await runtime.sessions.add({
- id: SID,
- snapshot: { blank: false },
- session: { loadOlder },
- })
- const reference = runtime.sessions.retain(SID)
- await reference.ready
- const trajectoryStore = createSnapshotStore(snapshot)
- const conversationStore = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(snapshot))
- const uiConversation = new UiConversation(ctx, runtime.sessions)
- const { events, views } = uiConversation
- const targetSources: ConversationTargetSources = {
- chat: createSnapshotStore<ChatSnapshot | undefined>(undefined),
- trajectory: trajectoryStore,
- }
- const binding: ConversationBinding = {
- snapshot: conversationStore,
- activate: () => {},
- target: target => targetSources[target],
- }
- vi.spyOn(uiConversation, 'binding').mockReturnValue(binding)
- // The conversation entry's role: declare the ring, then seed the chat entry.
- await runtime.root.declare(
- { '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)
- // The locale plugin backs the locale-aware view tab label ('locale' in
- // inject); its settings scope needs a connection handle.
- ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
- ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
- await runtime.mount({ inject: [...localeInject], apply: localeApply })
- const provide = vi.spyOn(ctx.uiSession, 'provide')
- const feature = await runtime.mount({ inject: [...inject], apply })
- const sourceDescriptor = provide.mock.calls[0]?.[0]
- if (sourceDescriptor === undefined) throw new Error('ui-trajectory did not provide its standard source')
- return {
- runtime, ctx, slots, feature, loadOlder, trajectoryStore, conversationStore,
- events, views, sourceDescriptor, reference,
- }
- }
- /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
- function tabsOf(slots: SlotRegistry): ViewTab[] {
- return slots.entries('conversation.view')
- .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
- }
- type ConvViewOwner = Pick<ConvViewProps, 'viewRequest' | 'openView' | 'completeViewRequest'>
- function isConvViewOwner(owner: object): owner is ConvViewOwner {
- return 'viewRequest' in owner
- && 'openView' in owner && typeof owner.openView === 'function'
- && 'completeViewRequest' in owner && typeof owner.completeViewRequest === 'function'
- }
- /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
- function mount(fixture: Awaited<ReturnType<typeof bench>>) {
- const { slots, trajectoryStore, conversationStore } = fixture
- const session = fixture.reference.binding.session
- const useSession = bindSnapshotSelector<SessionSnapshot>(session)
- const useTrajectory = bindSnapshotSelector<TrajectorySnapshot>(trajectoryStore)
- const useConversation = bindSnapshotSelector<ConversationSnapshot>(conversationStore)
- const useChat = bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT))
- const useSessions = emptySessions()
- const useSessionStatus = bindSnapshotSelector(
- createSnapshotStore<SessionStatusSnapshot>(new Map()),
- )
- const useWorkspaces = emptyWorkspaces()
- const conversation = createConversationStore().create()
- const useConversationViews = bindSnapshotSelector(
- createSnapshotStore<readonly ViewTab[]>(tabsOf(slots)),
- )
- const useInput = bindSnapshotSelector(createSnapshotStore<InputState>({
- draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
- }))
- const inputActions: InputActions = {
- setDraft: vi.fn(),
- addAttachments: vi.fn(() => false),
- removeAttachment: vi.fn(),
- pruneAttachments: vi.fn(),
- submit: vi.fn(),
- }
- const standardProps = {
- sessionId: SID,
- useSession,
- useTrajectory,
- useChat,
- useConversation,
- useConversationViews,
- useSessions,
- usePanelInfo, useResource,
- useSessionStatus,
- useSessionRetainInfo: () => undefined,
- useWorkspaces,
- useProjection,
- useInput,
- inputActions,
- }
- // 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: ConversationSessionProps['renderSlot'] = (key, owner, opts): ReactNode => {
- const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
- if (entry === undefined) return null
- if (!isConvViewOwner(owner)) throw new Error('trajectory fixture expected Conversation view owner props')
- 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
- ? (() => {
- const trajectory = injected as TrajectoryViewInjected
- return {
- loadOlder: trajectory.loadOlder,
- setActualDuration: trajectory.setActualDuration,
- useDuration: bindSnapshotSelector(trajectory.hooks.duration),
- t: tZh,
- }
- })()
- : injected
- const viewProps: ConvViewProps = { ...owner, ...standardProps }
- return (
- <View
- {...viewProps}
- {...injectedProps}
- key={key}
- />
- )
- }
- return render(
- <>
- <ConversationSessionHeader
- {...standardProps}
- SessionProvider={({ children }) => children}
- useStore={bindSnapshotSelector(conversation)}
- actions={conversation.actions}
- renderSlot={() => null}
- open={vi.fn()}
- selectView={conversation.actions.setView}
- t={tConversation}
- />
- <ConversationSession
- {...standardProps}
- SessionProvider={({ children }) => children}
- useStore={bindSnapshotSelector(conversation)}
- actions={conversation.actions}
- renderSlot={renderSlot}
- bindDraftMirror={() => () => {}}
- openView={conversation.actions.openView}
- />
- </>,
- )
- }
- 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()
- expect(b.events.entries().length).toBeGreaterThan(0)
- expect(b.views.entries()).toHaveLength(1)
- await b.feature.dispose()
- expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
- expect(b.events.entries()).toEqual([])
- expect(b.views.entries()).toEqual([])
- })
- it('keeps one total standard source for a Session binding', async () => {
- const b = await bench()
- using reference = b.runtime.sessions.retain(SID)
- await reference.ready
- const binding = reference.binding
- const resolveSource = (owner: SessionBinding): ObservableSnapshot<TrajectorySnapshot> => {
- const contribution = b.sourceDescriptor.resolve(owner) as {
- hooks: { trajectory: ObservableSnapshot<TrajectorySnapshot> }
- }
- return contribution.hooks.trajectory
- }
- const source = b.runtime.ctx.uiSession.adapter.bindingSource(reference).getSnapshot().hooks.trajectory as
- ObservableSnapshot<TrajectorySnapshot>
- expect(resolveSource(binding)).toBe(source)
- expect(resolveSource(binding)).toBe(source)
- const optionalTrajectory = b.trajectoryStore as unknown as {
- set(value: TrajectorySnapshot | undefined): void
- }
- optionalTrajectory.set(undefined)
- expect(source.getSnapshot()).toBe(EMPTY_TRAJECTORY_SNAPSHOT)
- })
- it('shares one browser-wide duration preference across session injections', async () => {
- const b = await bench()
- const entry = b.slots.entries('conversation.view')
- .find(candidate => candidate.options.id === 'trajectory')
- expect(entry).toBeDefined()
- const injectEntry = entry!.inject as unknown as (
- sessionId: SessionId,
- ) => TrajectoryViewInjected
- const first = injectEntry(SID)
- await b.runtime.sessions.add({ id: 's2' })
- using reference = b.runtime.sessions.retain('s2' as SessionId)
- await reference.ready
- const second = injectEntry(reference.sessionId)
- expect(second.hooks.duration).toBe(first.hooks.duration)
- first.setActualDuration(true)
- expect(second.hooks.duration.getSnapshot()).toBe(true)
- expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
- expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
- })
- it('reports whether loading older history changed the Trajectory snapshot', async () => {
- const b = await bench()
- const entry = b.slots.entries('conversation.view')
- .find(candidate => candidate.options.id === 'trajectory')
- const injectEntry = entry!.inject as unknown as (
- sessionId: SessionId,
- ) => TrajectoryViewInjected
- const injected = injectEntry(SID)
- expect(await injected.loadOlder()).toBe(false)
- b.loadOlder.mockImplementationOnce(async () => {
- b.trajectoryStore.set(historySnapshot([...NODES]))
- })
- expect(await injected.loadOlder()).toBe(true)
- })
- })
- 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)
- 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: '轨迹工具栏' })).toBeTruthy()
- expect(screen.getByRole('region', { name: '轨迹时间线' })).toBeTruthy()
- expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
- fireEvent.click(screen.getByRole('button', { name: '收起所有轮次' }))
- expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
- fireEvent.click(screen.getByRole('button', { name: '展开所有轮次' }))
- expect(screen.getByRole('row', { name: /用户/ })).toBeTruthy()
- expect(screen.queryByTestId('chat-body')).toBeNull()
- expect(b.loadOlder).not.toHaveBeenCalled()
- fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
- expect(b.loadOlder).not.toHaveBeenCalled()
- })
- it('labels the trajectory tab in the active locale', async () => {
- const b = await bench()
- const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label
- expect(labelOf()).toBe('Trajectory')
- const locale = b.ctx.get('locale') as { setLocale(id: string): void }
- locale.setLocale('zh')
- expect(labelOf()).toBe('轨迹')
- locale.setLocale('en')
- expect(labelOf()).toBe('Trajectory')
- })
- it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
- const b = await bench()
- mount(b)
- fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
- fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' })
- expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy()
- expect(screen.getByText('第 1 轮 · 第 1 步')).toBeTruthy()
- expect(screen.getByText('已完成')).toBeTruthy()
- expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy()
- fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
- expect(screen.queryByRole('complementary', { name: '事件详情' })).toBeNull()
- })
- it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => {
- const nodes: LegacyConversationSlice['nodes'] = [
- { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
- {
- kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
- blocks: [{ kind: 'text', text: 'before' }],
- },
- { kind: 'user', seq: 5, time: 5_000, content: [], source: null },
- {
- kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
- blocks: [{ kind: 'text', text: 'after' }],
- },
- ]
- const compaction: RequestView = {
- purpose: 'compaction',
- startSeq: 3,
- turn: null,
- step: 0,
- startedAt: 3_000,
- completedAt: 4_000,
- status: 'complete',
- summary: [{ type: 'text', text: 'standalone summary' }],
- }
- const b = await bench(historySnapshot(nodes, { requests: [compaction] }))
- const view = mount(b)
- fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
- expect(screen.getByText('轮次之间')).toBeTruthy()
- expect(view.container.textContent).not.toContain('Turn null')
- fireEvent.click(screen.getByRole('button', { name: '请求 #2 · 压缩' }))
- expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy()
- expect(view.container.textContent).not.toContain('Turn null')
- })
- it('activates only the selected standalone compaction section', async () => {
- const nodes: LegacyConversationSlice['nodes'] = [
- { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
- {
- kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
- blocks: [{ kind: 'text', text: 'before first compaction' }],
- },
- { kind: 'user', seq: 5, time: 5_000, content: [], source: null },
- {
- kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
- blocks: [{ kind: 'text', text: 'between compactions' }],
- },
- { kind: 'user', seq: 9, time: 9_000, content: [], source: null },
- {
- kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1,
- blocks: [{ kind: 'text', text: 'after second compaction' }],
- },
- ]
- const compactions: RequestView[] = [
- {
- purpose: 'compaction',
- startSeq: 3,
- turn: null,
- step: 0,
- startedAt: 3_000,
- completedAt: 4_000,
- status: 'complete',
- summary: [{ type: 'text', text: 'first standalone summary' }],
- },
- {
- purpose: 'compaction',
- startSeq: 7,
- turn: null,
- step: 0,
- startedAt: 7_000,
- completedAt: 8_000,
- status: 'complete',
- summary: [{ type: 'text', text: 'second standalone summary' }],
- },
- ]
- const b = await bench(historySnapshot(nodes, { requests: compactions }))
- mount(b)
- fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
- const firstRequest = screen.getByRole('button', { name: '请求 #2 · 压缩' })
- const secondRequest = screen.getByRole('button', { name: '请求 #4 · 压缩' })
- const firstSection = firstRequest.closest('tr')?.querySelector('span')
- const secondSection = secondRequest.closest('tr')?.querySelector('span')
- expect(firstSection?.textContent).toBe('轮次之间')
- expect(secondSection?.textContent).toBe('轮次之间')
- fireEvent.click(firstRequest)
- expect(firstSection?.className).toMatch(/turnLabelActive/)
- expect(secondSection?.className).not.toMatch(/turnLabelActive/)
- expect(screen.getByText('请求 #2')).toBeTruthy()
- expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy()
- fireEvent.click(secondRequest)
- expect(firstSection?.className).not.toMatch(/turnLabelActive/)
- expect(secondSection?.className).toMatch(/turnLabelActive/)
- expect(screen.getByText('请求 #4')).toBeTruthy()
- expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy()
- })
- it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
- const b = await bench()
- mount(b)
- fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
- const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件')
- 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: /用户/ }).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: /用户/ }).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: /用户/ }).getAttribute('data-timeline-focus'))
- .toBe('outside')
- fireEvent.contextMenu(plot)
- expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus'))
- .toBe('outside')
- })
- it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
- const b = await bench()
- const view = mount(b)
- fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
- const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件')
- 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: '事件详情' })).toBeTruthy()
- })
- it('empty window keeps the toolbar and reports no timing data', async () => {
- const b = await bench(historySnapshot([]))
- mount(b)
- fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
- expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
- expect(screen.getByText('无计时数据')).toBeTruthy()
- expect(screen.getByRole<HTMLButtonElement>('button', {
- name: '收起所有轮次',
- }).disabled).toBe(false)
- expect(screen.getByRole<HTMLButtonElement>('button', {
- name: '收起所有调用',
- }).disabled).toBe(false)
- 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('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => {
- vi.useFakeTimers()
- try {
- const view = render(
- <TrajectoryTimeline
- turns={[{
- turn: 1,
- groups: [{
- title: 'Step 1',
- cells: [{
- index: 1,
- kind: 'message',
- text: 'assistant',
- startedAt: 1_000,
- timeSeconds: 2,
- assistantMetrics: {
- timingRecorded: true,
- stepStartTime: 1_000,
- firstTokenTime: 1_500,
- completedTime: 3_000,
- usageProvided: false,
- outputTokens: null,
- },
- }],
- }],
- }]}
- mode="duration"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- const span = view.container.querySelector<HTMLElement>(
- '[data-timeline-span="message"]',
- )
- expect(span?.getAttribute('title')).toBeNull()
- expect(span?.getAttribute('data-assistant-timing')).toBe('true')
- expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%')
- fireEvent.mouseEnter(span as HTMLElement)
- act(() => { vi.advanceTimersByTime(499) })
- expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
- act(() => { vi.advanceTimersByTime(1) })
- const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
- expect(tooltip?.textContent).toContain('Total 2,000 ms')
- expect(tooltip?.textContent).toContain('TTFT 500 ms')
- expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
- } finally {
- vi.useRealTimers()
- }
- })
- it('marks an unloaded history prefix without inventing timeline duration', () => {
- const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {}))
- const view = render(
- <TrajectoryTimeline
- turns={turns}
- mode="sequence"
- range={null}
- hasEarlierRecords
- onLoadEarlier={onLoadEarlier}
- onRangeChange={vi.fn()}
- />,
- )
- const boundary = screen.getByLabelText('Load earlier history')
- expect(boundary.getAttribute('data-earlier-history')).not.toBeNull()
- const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
- fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 })
- expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy()
- fireEvent.pointerEnter(boundary)
- expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
- fireEvent.focus(boundary)
- expect(screen.getByRole('tooltip').textContent)
- .toContain('Click to load earlier history')
- fireEvent.click(boundary)
- expect(onLoadEarlier).toHaveBeenCalledOnce()
- expect(screen.getByLabelText('Loading earlier history…')).toBeTruthy()
- view.rerender(
- <TrajectoryTimeline
- turns={turns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- expect(screen.queryByLabelText('Load earlier history')).toBeNull()
- expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
- })
- it('cancels native scrolling across the timeline while zooming', () => {
- render(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
- vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
- x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
- toJSON: () => ({}),
- })
- expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
- expect(fireEvent.wheel(screen.getByText('Input'), {
- clientX: 20,
- deltaY: -100,
- })).toBe(false)
- })
- it('scales sequence gutters with narrow operation spans', () => {
- const view = render(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
- expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
- expect(span?.style.getPropertyValue('--trajectory-span-gap'))
- .toBe('min(0.8%, 1px)')
- })
- it('keeps dense sequence spans proportional before applying the pixel floor', () => {
- const denseTurns = [{
- turn: 1,
- groups: [{
- title: 'Step 1',
- cells: Array.from({ length: 400 }, (_, index) => ({
- index,
- kind: 'message' as const,
- text: `message ${index}`,
- timeSeconds: 1,
- })),
- }],
- }]
- const view = render(
- <TrajectoryTimeline
- turns={denseTurns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
- expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%')
- expect(span?.style.getPropertyValue('--trajectory-span-gap'))
- .toBe('min(0.02%, 1px)')
- })
- it('clears the selection without changing zoom on a zoomed right click', () => {
- const onRangeChange = vi.fn()
- const view = render(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={{ start: 2, end: 4 }}
- hasEarlierRecords
- onRangeChange={onRangeChange}
- />,
- )
- const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
- expect(screen.getByLabelText('Load earlier history')).toBeTruthy()
- 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 })
- expect(screen.queryByLabelText('Load earlier history')).toBeNull()
- const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
- const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
- expect(domainWidth).not.toBe('100%')
- fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
- expect(fireEvent.contextMenu(plot)).toBe(false)
- fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
- expect(onRangeChange).toHaveBeenCalledOnce()
- expect(onRangeChange).toHaveBeenCalledWith(null)
- expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth)
- })
- it('clears the selection and suppresses the context menu at full zoom', () => {
- const onRangeChange = vi.fn()
- render(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={{ start: 2, end: 4 }}
- onRangeChange={onRangeChange}
- />,
- )
- const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
- fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
- expect(fireEvent.contextMenu(plot)).toBe(false)
- fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
- expect(onRangeChange).toHaveBeenCalledOnce()
- expect(onRangeChange).toHaveBeenCalledWith(null)
- })
- it('pans the zoomed viewport with a right-button drag without changing the selection', () => {
- const onRangeChange = vi.fn()
- const view = render(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={{ start: 2, end: 4 }}
- 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 })
- const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
- const before = domain?.style.getPropertyValue('--trajectory-domain-left')
- fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
- expect(plot.getAttribute('data-panning')).toBe('true')
- expect(fireEvent.contextMenu(plot)).toBe(false)
- fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 })
- fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 })
- expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before)
- expect(onRangeChange).not.toHaveBeenCalled()
- expect(plot.getAttribute('data-panning')).toBeNull()
- })
- 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()
- 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 })
- fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
- for (let index = 0; index < 24; index++) {
- fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
- }
- const draftSelection = view.container.querySelectorAll<HTMLElement>(
- '[data-dragging="true"]',
- )
- expect(draftSelection).toHaveLength(2)
- for (const overlay of draftSelection) {
- expect(Number.parseFloat(
- overlay.style.getPropertyValue('--trajectory-selection-left'),
- )).toBeLessThan(0)
- }
- fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
- const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
- | { start: number; end: number }
- | undefined
- const fullRange = deriveTrajectoryTimeline(longTurns)
- expect(selectedRange).toBeDefined()
- expect(fullRange).not.toBeNull()
- expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
- expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
- expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
- })
- 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('compresses every idle gap in duration mode while actual mode retains wall time', () => {
- 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, 'duration')).toMatchObject({
- start: 1_000,
- end: 4_000,
- spans: [
- { index: 1, start: 1_000, end: 2_000 },
- { index: 2, start: 2_000, end: 3_000 },
- { index: 3, start: 3_000, end: 4_000 },
- ],
- turnBoundaries: [
- { turn: 1, time: 1_000 },
- { turn: 2, time: 3_000 },
- ],
- })
- expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
- start: 1_000,
- end: 41_000,
- spans: [
- { index: 1, start: 1_000, end: 2_000 },
- { index: 2, start: 4_000, end: 5_000 },
- { index: 3, start: 40_000, end: 41_000 },
- ],
- })
- })
- it('projects between-turn compaction without inventing a turn boundary', () => {
- const withStandaloneCompaction = [
- {
- turn: 1,
- groups: [{
- title: 'Step 1',
- cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }],
- }],
- },
- {
- turn: null,
- groups: [{
- title: 'Compaction 3',
- cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }],
- }],
- },
- {
- turn: 2,
- groups: [{
- title: 'Step 1',
- cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }],
- }],
- },
- ] satisfies readonly TrajectoryTurnModel[]
- expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({
- 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([])),
- ...standaloneDuration(),
- },
- ))
- expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
- expect(screen.queryByRole('row')).toBeNull()
- })
- })
- describe('TrajectoryView state', () => {
- it('reveals resident history one bounded page at a time', async () => {
- const nodes: LegacyConversationSlice['nodes'] = Array.from({ length: 5_000 }, (_, index) => ({
- kind: 'user' as const,
- seq: index + 1,
- time: index + 1,
- content: [{ type: 'text' as const, text: `prompt ${String(index + 1)}` }],
- source: null,
- }))
- const trajectory = createSnapshotStore(historySnapshot([]))
- const loadOlder = vi.fn(() => Promise.resolve(false))
- render(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneHistory(historySnapshot(nodes))}
- {...standaloneDuration()}
- useTrajectory={bindSnapshotSelector(trajectory)}
- loadOlder={loadOlder}
- />,
- )
- act(() => { trajectory.set(historySnapshot(nodes)) })
- expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
- fireEvent.click(screen.getAllByRole('button', { name: '加载更早的历史' }).at(-1)!)
- expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('101')
- expect(loadOlder).not.toHaveBeenCalled()
- act(() => { trajectory.set(historySnapshot([...nodes, {
- kind: 'user', seq: 5_001, time: 5_001,
- content: [{ type: 'text', text: 'appended prompt' }], source: null,
- }])) })
- expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('102')
- })
- it('keeps a replacement window bounded when its predecessor tail is absent', () => {
- const nodes = (start: number): LegacyConversationSlice['nodes'] => Array.from(
- { length: 100 },
- (_, index) => ({
- kind: 'user' as const,
- seq: start + index,
- time: start + index,
- content: [],
- source: null,
- }),
- )
- const trajectory = createSnapshotStore(historySnapshot(nodes(1)))
- render(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneHistory(historySnapshot([]))}
- {...standaloneDuration()}
- useTrajectory={bindSnapshotSelector(trajectory)}
- />,
- )
- expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
- act(() => { trajectory.set(historySnapshot(nodes(201))) })
- expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
- })
- it('keeps resident request numbering and cumulative usage outside the layout page', async () => {
- vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
- Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
- configurable: true,
- value: vi.fn(),
- })
- const visibleNodes: LegacyConversationSlice['nodes'] = Array.from(
- { length: 10 },
- (_, index) => ({
- kind: 'assistant' as const,
- seq: index * 2 + 44,
- time: index * 2 + 44,
- turn: index + 2,
- step: 1,
- blocks: [{ kind: 'text' as const, text: `visible response ${String(index + 1)}` }],
- }),
- )
- const nodes: LegacyConversationSlice['nodes'] = [{
- kind: 'assistant', seq: 2, time: 2, turn: 1, step: 1,
- blocks: [{ kind: 'tool-call', callId: 'boundary-call', name: 'bash', argsRaw: '{}' }],
- }, {
- kind: 'tool-result', seq: 3, time: 3, callId: 'boundary-call',
- call: { name: 'bash', argsRaw: '{}' }, callTime: 2,
- content: [], isError: false, subCalls: [],
- }, ...Array.from({ length: 39 }, (_, index) => ({
- kind: 'compaction' as const,
- seq: index + 4,
- time: index + 4,
- summary: null,
- summaryEventSeq: null,
- shadowedItemCount: null,
- shadowedTokenCount: null,
- })), ...visibleNodes]
- const requests: readonly RequestView[] = [{
- purpose: 'assistant', startSeq: 1, resultSeq: 2, startedAt: 1, completedAt: 2,
- status: 'complete', turn: 1, step: 1, usage: { inputTokens: 1 },
- }, ...visibleNodes.map((node, index) => ({
- purpose: 'assistant' as const,
- startSeq: node.seq - 1,
- resultSeq: node.seq,
- startedAt: node.seq - 1,
- completedAt: node.seq,
- status: 'complete' as const,
- turn: index + 2,
- step: 1,
- usage: { inputTokens: 1 },
- }))]
- render(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneHistory(historySnapshot(nodes, { requests }))}
- {...standaloneDuration()}
- />,
- )
- await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #1' })).toBeTruthy() })
- await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #11' })).toBeTruthy() })
- fireEvent.click(screen.getByRole('button', { name: '请求 #11' }))
- fireEvent.click(screen.getByRole('tab', { name: '用量' }))
- expect(screen.getByText('会话累计').closest('section')?.textContent).toContain('11 tok')
- })
- it.each([
- ['root', {
- kind: 'assistant' as const,
- seq: 3,
- time: 3,
- turn: 1,
- step: 1,
- blocks: [{ kind: 'tool-call' as const, callId: 'hidden-root', name: 'bash', argsRaw: '{}' }],
- }, 'hidden-root'],
- ['nested', {
- kind: 'tool-result' as const,
- seq: 3,
- time: 3,
- callId: 'hidden-root',
- call: { name: 'run_code', argsRaw: '{}' },
- callTime: 2,
- content: [],
- isError: false,
- subCalls: [{
- callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}',
- turn: 1, step: 1, time: 3, subCalls: [],
- }],
- }, 'hidden-child'],
- ])('reveals a hidden resident %s call for cross-view inspection', (_kind, target, focus) => {
- const nodes: LegacyConversationSlice['nodes'] = [
- { kind: 'user', seq: 1, time: 1, content: [], source: null },
- {
- kind: 'tool-result', seq: 2, time: 2, callId: 'unrelated', call: null, callTime: null,
- content: [], isError: false, subCalls: [],
- },
- target,
- ...Array.from({ length: 50 }, (_, index) => ({
- kind: 'user' as const,
- seq: index + 4,
- time: index + 4,
- content: [{ type: 'text' as const, text: `later ${String(index + 1)}` }],
- source: null,
- })),
- ]
- const completeViewRequest = vi.fn()
- render(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneHistory(historySnapshot(nodes))}
- {...standaloneDuration()}
- viewRequest={{ view: 'trajectory', focus }}
- completeViewRequest={completeViewRequest}
- />,
- )
- expect(completeViewRequest).toHaveBeenCalledOnce()
- })
- it('persists the duration preference through the runtime snapshot-store seam', () => {
- const firstDuration = createTrajectoryDurationStore()
- const commonProps = {
- ...standaloneProps(NODES),
- ...standaloneHistory(historySnapshot(NODES)),
- }
- const first = render(
- <TrajectoryView
- {...commonProps}
- useDuration={bindSnapshotSelector(firstDuration)}
- setActualDuration={(value) => { firstDuration.set(value) }}
- />,
- )
- const duration = screen.getByRole('button', { name: '使用实际时长' })
- expect(duration.getAttribute('aria-pressed')).toBe('false')
- fireEvent.click(duration)
- expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
- first.unmount()
- const restoredDuration = createTrajectoryDurationStore()
- render(
- <TrajectoryView
- {...commonProps}
- useDuration={bindSnapshotSelector(restoredDuration)}
- setActualDuration={(value) => { restoredDuration.set(value) }}
- />,
- )
- expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed'))
- .toBe('true')
- })
- it('keeps ledger and timeline selection on the same event after prepend', () => {
- const older: LegacyConversationSlice['nodes'][number] = {
- kind: 'user', seq: 1, time: 1_000,
- content: [{ type: 'text', text: 'older prompt' }], source: null,
- }
- const current: LegacyConversationSlice['nodes'][number] = {
- kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1,
- blocks: [{ kind: 'text', text: 'selected current response' }],
- }
- const store = createSnapshotStore(historySnapshot([current]))
- const view = render(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneHistory(historySnapshot([]))}
- {...standaloneDuration()}
- useTrajectory={bindSnapshotSelector(store)}
- loadOlder={vi.fn(() => Promise.resolve(false))}
- />,
- )
- fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
- act(() => { store.set(historySnapshot([older, current])) })
- const row = screen.getByRole('row', { name: /selected current response/ })
- expect(row.getAttribute('aria-selected')).toBe('true')
- const currentIndex = row.getAttribute('data-record-index')
- expect(view.container.querySelector(
- `[data-timeline-record-index="${currentIndex}"][data-current="true"]`,
- )).toBeTruthy()
- })
- })
- describe('node half', () => {
- it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
- expect(() => { nodeApply() }).not.toThrow()
- })
- })
|