Kaynağa Gözat

feat(ui-trajectory): assemble registered conversation nodes

imccyu 2 ay önce
ebeveyn
işleme
f479c60b6d

+ 14 - 26
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -1,11 +1,11 @@
 /** Trajectory view: compact summary over a turn-aware event ledger. */
 /** Trajectory view: compact summary over a turn-aware event ledger. */
 
 
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
 import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
 import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
 import type {
   AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
   AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
-  SessionHistoryFace, SnapshotStore,
+  SnapshotStore,
 } from '@deepseek-ai/dsh-client-runtime/client'
 } from '@deepseek-ai/dsh-client-runtime/client'
 import {
 import {
   deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
   deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
@@ -27,6 +27,7 @@ import {
   type TrajectoryTimeRange,
   type TrajectoryTimeRange,
 } from './timeline.ts'
 } from './timeline.ts'
 import { trajectoryRecordId } from './trajectory-record.ts'
 import { trajectoryRecordId } from './trajectory-record.ts'
+import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
 import css from './views.module.css'
 import css from './views.module.css'
 
 
 const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
 const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
@@ -64,14 +65,12 @@ function partialStructureSignature(partial: ConversationSnapshot['partial']): st
     : block.kind).join('\u0000')
     : block.kind).join('\u0000')
 }
 }
 
 
-/** Session-history paging needed by the event-complete trajectory view. */
+/** Session-bound controls not already supplied by the conversation view slot. */
 export interface TrajectoryViewInjected {
 export interface TrajectoryViewInjected {
   hooks: {
   hooks: {
-    history: SessionHistoryFace
     duration: SnapshotStore<boolean>
     duration: SnapshotStore<boolean>
   }
   }
-  loadHistoryTail: (signal: AbortSignal) => Promise<void>
-  loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
+  loadOlder: () => Promise<boolean>
   setActualDuration: (actualDuration: boolean) => void
   setActualDuration: (actualDuration: boolean) => void
 }
 }
 
 
@@ -184,7 +183,7 @@ function mergeSearchMatches(
 }
 }
 
 
 export function TrajectoryView({
 export function TrajectoryView({
-  useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
+  useSession, useDuration, loadOlder, setActualDuration,
   inspect, onInspectDone,
   inspect, onInspectDone,
 }: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
 }: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
   const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
   const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
@@ -204,23 +203,15 @@ export function TrajectoryView({
   const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
   const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
     readonly index: number
     readonly index: number
   } | null>(null)
   } | null>(null)
-  const inspection = useHistory(snapshot => snapshot.inspection)
-  const historyLoading = useHistory(snapshot =>
-    snapshot.state === 'cold' || snapshot.state === 'loading')
-  const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
-  const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
+  const inspection = useSession(snapshot =>
+    snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
+  const historyLoading = useSession(snapshot =>
+    snapshot.openState === 'loading' || snapshot.loadingOlder)
+  const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
   const nodes = inspection.eventNodes
   const nodes = inspection.eventNodes
+  const historyBaseSeq = nodes[0]?.seq ?? 0
   const partial = inspection.partial
   const partial = inspection.partial
   const runningCalls = inspection.runningCalls
   const runningCalls = inspection.runningCalls
-  const loadHistoryTailRef = useRef(loadHistoryTail)
-  loadHistoryTailRef.current = loadHistoryTail
-  const historyControllerRef = useRef<AbortController | null>(null)
-  useEffect(() => {
-    const controller = new AbortController()
-    historyControllerRef.current = controller
-    void loadHistoryTailRef.current(controller.signal)
-    return () => { controller.abort() }
-  }, [])
   const requests = inspection.requests
   const requests = inspection.requests
   const callSchemas = inspection.callSchemas
   const callSchemas = inspection.callSchemas
   const historyContexts = inspection.contexts
   const historyContexts = inspection.contexts
@@ -518,11 +509,8 @@ export function TrajectoryView({
   }
   }
 
 
   const loadEarlierHistory = useCallback(() => {
   const loadEarlierHistory = useCallback(() => {
-    const signal = historyControllerRef.current?.signal
-    return signal?.aborted === false
-      ? loadOlderHistory(signal)
-      : Promise.resolve(false)
-  }, [loadOlderHistory])
+    return loadOlder()
+  }, [loadOlder])
 
 
   return (
   return (
     <div className={css.root} data-conversation-composer-overlay="">
     <div className={css.root} data-conversation-composer-overlay="">

+ 24 - 6
packages/client/ui-trajectory/src/client/index.ts

@@ -9,9 +9,15 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { createTrajectoryDurationStore } from './duration-store.ts'
 import { createTrajectoryDurationStore } from './duration-store.ts'
 import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
 import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
+import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
+import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
+import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts'
+import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts'
+import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts'
+import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts'
 
 
-/** Required services: the conversation view slot and independent history source. */
-export const inject = ['slots', 'sessionHistory']
+/** Required services: the conversation slot, registries, and ordinary Session paging. */
+export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions']
 
 
 /**
 /**
  * Client plugin body: register the trajectory view tab. The registration
  * Client plugin body: register the trajectory view tab. The registration
@@ -20,17 +26,29 @@ export const inject = ['slots', 'sessionHistory']
  */
  */
 export function apply(ctx: Context): void {
 export function apply(ctx: Context): void {
   const duration = createTrajectoryDurationStore()
   const duration = createTrajectoryDurationStore()
+  registerTrajectoryMessageDefinitions(ctx)
+  registerTrajectoryRequestHeaderDefinition(ctx)
+  registerTrajectoryAssistantDefinition(ctx)
+  registerTrajectoryToolDefinition(ctx)
+  registerTrajectoryCompactionDefinitions(ctx)
+  registerTrajectoryConversationView(ctx)
   ctx.slots.inject('conversation.view', () => ctx.slots.register({
   ctx.slots.inject('conversation.view', () => ctx.slots.register({
     name: 'conversation.view',
     name: 'conversation.view',
     id: 'trajectory',
     id: 'trajectory',
     order: 10,
     order: 10,
     label: 'Trajectory',
     label: 'Trajectory',
     inject: (sessionId: SessionId): TrajectoryViewInjected => {
     inject: (sessionId: SessionId): TrajectoryViewInjected => {
-      const history = ctx.sessionHistory.source(sessionId)
+      const session = ctx.sessions.binding(sessionId)?.session
+      if (session === undefined) {
+        throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`)
+      }
       return {
       return {
-        hooks: { history, duration },
-        loadHistoryTail: signal => history.loadTail(signal),
-        loadOlderHistory: signal => history.loadOlder(signal),
+        hooks: { duration },
+        loadOlder: async () => {
+          const hadMore = session.getSnapshot().hasMore
+          await session.loadOlder()
+          return hadMore
+        },
         setActualDuration: (value) => { duration.set(value) },
         setActualDuration: (value) => { duration.set(value) },
       }
       }
     },
     },

+ 397 - 0
packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts

@@ -0,0 +1,397 @@
+import type { Context } from 'cordis'
+import type {
+  AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
+  ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import {
+  displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
+  toAssistantBlocks,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import { trajectoryNode } from './trajectory-definition-common.ts'
+
+interface UsageValue {
+  readonly inputTokens: number
+  readonly outputTokens: number
+  readonly cacheReadTokens?: number
+  readonly cacheWriteTokens?: number
+  readonly reasoningTokens?: number
+}
+
+interface RetryValue {
+  readonly message: string
+  readonly retry: number
+  readonly maxRetries?: number
+  readonly delayMs: number
+}
+
+interface AssistantState {
+  readonly turn: number
+  readonly step: number
+  readonly startSeq: number
+  readonly startTime: number
+  readonly started: boolean
+  readonly sawChunk: boolean
+  readonly blocks: readonly (AssistantBlock | undefined)[]
+  readonly firstVisibleSeq: number | undefined
+  readonly firstVisibleTime: number | undefined
+  readonly firstTokenTime: number | undefined
+  readonly final: ConversationMatch | undefined
+  readonly usage: UsageValue | undefined
+  readonly retry: RetryValue | undefined
+  readonly stepEnd: ConversationMatch | undefined
+}
+
+function initialState(
+  turn: number,
+  step: number,
+  startSeq: number,
+  startTime: number,
+  started: boolean,
+): AssistantState {
+  return {
+    turn,
+    step,
+    startSeq,
+    startTime,
+    started,
+    sawChunk: false,
+    blocks: [],
+    firstVisibleSeq: undefined,
+    firstVisibleTime: undefined,
+    firstTokenTime: undefined,
+    final: undefined,
+    usage: undefined,
+    retry: undefined,
+    stepEnd: undefined,
+  }
+}
+
+function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] {
+  return blocks.filter((block): block is AssistantBlock => block !== undefined)
+}
+
+function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
+  return blocks.some((block) => {
+    if (block.kind === 'tool-call') return false
+    if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
+    return true
+  })
+}
+
+function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean {
+  return blocks.some((block) => {
+    if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
+    return true
+  })
+}
+
+function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue {
+  return {
+    inputTokens: (current?.inputTokens ?? 0) + next.inputTokens,
+    outputTokens: (current?.outputTokens ?? 0) + next.outputTokens,
+    ...(current?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
+      ? {}
+      : { cacheReadTokens: (current?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0) }),
+    ...(current?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
+      ? {}
+      : { cacheWriteTokens: (current?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0) }),
+    ...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
+      ? {}
+      : { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
+  }
+}
+
+function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState {
+  if (match.event.type !== 'assistant/chunk') return state
+  const chunk = match.event.data.chunk
+  if (chunk.type === 'usage') {
+    return { ...state, sawChunk: true, usage: addUsage(state.usage, chunk.usage) }
+  }
+  const blocks = [...state.blocks]
+  switch (chunk.type) {
+    case 'block-start':
+      blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
+      break
+    case 'text-delta': {
+      const previous = blocks[chunk.index]
+      blocks[chunk.index] = {
+        kind: 'text',
+        text: (previous?.kind === 'text' ? previous.text : '') + chunk.text,
+      }
+      break
+    }
+    case 'reasoning-delta': {
+      const previous = blocks[chunk.index]
+      blocks[chunk.index] = {
+        kind: 'reasoning',
+        text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text,
+      }
+      break
+    }
+    case 'tool-call-delta': {
+      const previous = blocks[chunk.index]
+      const base = previous?.kind === 'tool-call'
+        ? previous
+        : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
+      blocks[chunk.index] = {
+        kind: 'tool-call',
+        callId: base.callId || String(chunk.id),
+        name: chunk.name ?? base.name,
+        argsRaw: base.argsRaw + chunk.argumentsDelta,
+      }
+      break
+    }
+    case 'block-end':
+      blocks[chunk.index] = toAssistantBlock(chunk.block)
+      break
+    default:
+      return { ...state, sawChunk: true }
+  }
+  const visible = hasVisibleContent(compactBlocks(blocks))
+  return {
+    ...state,
+    sawChunk: true,
+    blocks,
+    ...(visible && state.firstVisibleSeq === undefined
+      ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
+      : {}),
+    ...(isTokenDelta(chunk) && state.firstTokenTime === undefined
+      ? { firstTokenTime: match.event.time }
+      : {}),
+  }
+}
+
+function closedBoundary(
+  context: ConversationNodeContext<AssistantState>,
+): { seq: number; time: number } | undefined {
+  if (context.state?.stepEnd?.event.type === 'step/end') return context.state.stepEnd.event
+  const location: ConversationLocation | undefined = context.start?.location
+    ?? context.matches.at(-1)?.location
+  if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
+  if ((location?.kind === 'step' || location?.kind === 'turn')
+    && location.turn.status === 'closed') return location.turn.end
+  return undefined
+}
+
+function fallbackState(context: ConversationNodeContext<AssistantState>): AssistantState | undefined {
+  let state: AssistantState | undefined
+  for (const match of context.matches) {
+    const event = match.event
+    if (event.type === 'assistant/chunk') {
+      state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
+      state = updateChunk(state, match)
+    } else if (event.type === 'assistant/message') {
+      state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
+      state = {
+        ...state,
+        blocks: toAssistantBlocks(event.data.message.content),
+        final: match,
+        usage: state.usage ?? event.data.usage,
+      }
+    } else if (event.type === 'step/end' && state !== undefined) {
+      state = { ...state, stepEnd: match }
+    }
+  }
+  return state
+}
+
+function finalNode(
+  state: AssistantState,
+  context: ConversationNodeContext<AssistantState>,
+): AssistantMessageNode | undefined {
+  const final = state.final
+  if (final?.event.type === 'assistant/message') {
+    const event = final.event
+    return {
+      kind: 'assistant',
+      seq: event.seq,
+      time: event.time,
+      turn: state.turn,
+      step: state.step,
+      blocks: toAssistantBlocks(event.data.message.content),
+      usage: event.data.usage,
+      provenance: {
+        provider: event.data.message.source.provider,
+        model: event.data.message.source.model,
+      },
+      timing: {
+        stepStartTime: state.started ? state.startTime : null,
+        firstTokenTime: state.firstTokenTime ?? null,
+        completedTime: event.time,
+      },
+    }
+  }
+  const boundary = closedBoundary(context)
+  const blocks = compactBlocks(state.blocks)
+  if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
+  return {
+    kind: 'assistant',
+    seq: boundary.seq - 0.9,
+    time: boundary.time,
+    turn: state.turn,
+    step: state.step,
+    blocks,
+    interrupted: true,
+  }
+}
+
+function assistantRequest(
+  state: AssistantState,
+  node: AssistantMessageNode | undefined,
+  boundary: { seq: number; time: number } | undefined,
+): Extract<RequestView, { purpose: 'assistant' }> | undefined {
+  if (!state.started) return undefined
+  const status = node !== undefined && node.interrupted !== true
+    ? 'complete'
+    : state.retry !== undefined || boundary !== undefined ? 'error' : 'running'
+  return {
+    purpose: 'assistant',
+    startSeq: state.startSeq,
+    turn: state.turn,
+    step: state.step,
+    startedAt: state.startTime,
+    completedAt: node?.time ?? boundary?.time ?? null,
+    status,
+    ...(state.retry === undefined
+      ? {}
+      : {
+          error: state.retry.message,
+          retry: state.retry.retry,
+          ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }),
+          retryDelayMs: state.retry.delayMs,
+        }),
+    ...(node === undefined || node.interrupted === true
+      ? {}
+      : {
+          resultSeq: node.seq,
+          ...(node.provenance === undefined ? {} : { provenance: node.provenance }),
+        }),
+    ...(state.usage === undefined ? {} : { usage: state.usage }),
+  }
+}
+
+/** Trajectory-owned Assistant streaming, settlement, and request lifecycle. */
+const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState> = {
+  kind: 'trajectory-assistant-step',
+  target: 'trajectory',
+  match: (event) => {
+    if (event.type === 'step/start') {
+      return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
+    }
+    if (event.type === 'assistant/chunk'
+      || event.type === 'assistant/message'
+      || event.type === 'llm/retry'
+      || event.type === 'step/end') {
+      return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
+    }
+    return null
+  },
+  start: (_context, match) => {
+    if (match.event.type !== 'step/start') {
+      throw new Error('trajectory-assistant-step start requires step/start')
+    }
+    return initialState(
+      match.event.data.turn,
+      match.event.data.step,
+      match.event.seq,
+      match.event.time,
+      true,
+    )
+  },
+  update: (context, match) => {
+    if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
+    if (match.event.type === 'assistant/message') {
+      return {
+        ...context.state,
+        blocks: toAssistantBlocks(match.event.data.message.content),
+        final: match,
+        usage: context.state.usage ?? match.event.data.usage,
+      }
+    }
+    if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
+    if (match.event.type !== 'llm/retry') return context.state
+    const data = match.event.data
+    return {
+      ...initialState(
+        context.state.turn,
+        context.state.step,
+        context.state.startSeq,
+        context.state.startTime,
+        true,
+      ),
+      firstTokenTime: context.state.firstTokenTime,
+      usage: context.state.usage,
+      retry: {
+        message: displayFailureMessage(data.failure),
+        retry: data.retry,
+        ...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}),
+        delayMs: data.delayMs,
+      },
+    }
+  },
+  publication: (match) => {
+    if (match.event.type === 'step/start') return 'none'
+    if (match.event.type !== 'assistant/chunk') return 'immediate'
+    const type = match.event.data.chunk.type
+    return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
+  },
+  buildViewNode: (context) => {
+    const state = context.state ?? fallbackState(context)
+    if (state === undefined) return null
+    const node = finalNode(state, context)
+    const boundary = closedBoundary(context)
+    const partial: PartialAssistant | null = node === undefined && boundary === undefined && state.sawChunk
+      ? { turn: state.turn, step: state.step, blocks: compactBlocks(state.blocks) }
+      : null
+    const request = assistantRequest(state, node, boundary)
+    if (node === undefined && partial === null && request === undefined) return null
+    return trajectoryNode(context, state.startSeq, {
+      kind: 'assistant',
+      ...(node === undefined ? {} : { node }),
+      partial,
+      ...(request === undefined ? {} : { request }),
+    })
+  },
+}
+
+interface TurnEndState {
+  readonly turn: number
+  readonly seq: number
+  readonly time: number
+  readonly error?: string
+}
+
+const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
+  kind: 'trajectory-turn-end',
+  target: 'trajectory',
+  match: event => event.type === 'turn/end'
+    ? { id: String(event.seq), role: 'start' }
+    : null,
+  start: (_context, match) => {
+    if (match.event.type !== 'turn/end') {
+      throw new Error('trajectory-turn-end start requires turn/end')
+    }
+    const reason = match.event.data.reason
+    return {
+      turn: match.event.data.turn,
+      seq: match.event.seq,
+      time: match.event.time,
+      ...(reason.kind === 'error' ? { error: displayFailureMessage(reason.error) } : {}),
+    }
+  },
+  update: context => context.state,
+  buildViewNode: context => context.state === undefined
+    ? null
+    : trajectoryNode(context, context.state.seq, {
+        kind: 'turn-end',
+        turn: context.state.turn,
+        time: context.state.time,
+        ...(context.state.error === undefined ? {} : { error: context.state.error }),
+      }),
+}
+
+/** Register the Trajectory Assistant lifecycle. */
+export function registerTrajectoryAssistantDefinition(ctx: Context): void {
+  ctx.conversationEvents.register(trajectoryAssistantDefinition)
+  ctx.conversationEvents.register(trajectoryTurnEndDefinition)
+}

+ 139 - 0
packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts

@@ -0,0 +1,139 @@
+import type { Context } from 'cordis'
+import type {
+  ConversationMatch, ConversationNodeDefinition, RequestView,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type {} from '@deepseek-ai/dsh-compact/types'
+import { trajectoryNode } from './trajectory-definition-common.ts'
+
+interface CompactionState {
+  readonly start: ConversationMatch
+  readonly summary?: ConversationMatch
+  readonly end?: ConversationMatch
+  readonly checkpoint?: ConversationMatch
+}
+
+function checkpointId(
+  event: Parameters<ConversationNodeDefinition['match']>[0],
+): string | undefined {
+  if (event.type !== 'user/message') return undefined
+  const source = event.data.source as unknown as {
+    readonly kind?: unknown
+    readonly plugin?: unknown
+    readonly compactionId?: unknown
+  }
+  return source.kind === 'plugin' && source.plugin === 'compact'
+    && typeof source.compactionId === 'string' && source.compactionId !== ''
+    ? source.compactionId
+    : undefined
+}
+
+function eventCompactionId(
+  event: Parameters<ConversationNodeDefinition['match']>[0],
+): string | undefined {
+  if (event.type !== 'compact/start'
+    && event.type !== 'compact/summary'
+    && event.type !== 'compact/end') return undefined
+  const value: unknown = event.data.compactionId
+  return typeof value === 'string' && value !== '' ? value : undefined
+}
+
+function requestFromState(
+  state: CompactionState,
+): Extract<RequestView, { purpose: 'compaction' }> | undefined {
+  const start = state.start.event
+  if (start.type !== 'compact/start') return undefined
+  const summary = state.summary?.event
+  const end = state.end?.event
+  const checkpoint = state.checkpoint?.event
+  return {
+    purpose: 'compaction',
+    startSeq: start.seq,
+    turn: start.data.turn,
+    step: 0,
+    startedAt: start.time,
+    completedAt: end?.type === 'compact/end' ? end.time : null,
+    status: end?.type !== 'compact/end'
+      ? 'running'
+      : end.data.error === undefined ? 'complete' : 'error',
+    ...(end?.type === 'compact/end' && end.data.error !== undefined
+      ? { error: end.data.error }
+      : {}),
+    ...(summary?.type !== 'compact/summary'
+      ? {}
+      : {
+          resultSeq: summary.seq,
+          summary: summary.data.summary,
+          ...(summary.data.rawOutput === undefined ? {} : { rawOutput: summary.data.rawOutput }),
+          provenance: { provider: summary.data.provider, model: summary.data.model },
+          requestConfig: {
+            provider: summary.data.provider,
+            model: summary.data.model,
+            purpose: 'compaction',
+            ...(summary.data.maxTokens === undefined ? {} : { maxTokens: summary.data.maxTokens }),
+          },
+          ...(summary.data.usage === undefined ? {} : { usage: summary.data.usage }),
+        }),
+    ...(checkpoint?.type === 'user/message' ? { replacementSeq: checkpoint.seq } : {}),
+  }
+}
+
+const trajectoryCompactionDefinition: ConversationNodeDefinition<CompactionState> = {
+  kind: 'trajectory-compaction',
+  target: 'trajectory',
+  match: (event) => {
+    const compactId = eventCompactionId(event)
+    if (compactId !== undefined) {
+      return { id: compactId, role: event.type === 'compact/start' ? 'start' : 'update' }
+    }
+    const checkpoint = checkpointId(event)
+    return checkpoint === undefined ? null : { id: checkpoint, role: 'update' }
+  },
+  start: (_context, match) => {
+    if (match.event.type !== 'compact/start') {
+      throw new Error('trajectory-compaction start requires compact/start')
+    }
+    return { start: match }
+  },
+  update: (context, match) => {
+    if (match.event.type === 'compact/summary') return { ...context.state, summary: match }
+    if (match.event.type === 'compact/end') return { ...context.state, end: match }
+    return checkpointId(match.event) === undefined
+      ? context.state
+      : { ...context.state, checkpoint: match }
+  },
+  buildViewNode: (context) => {
+    if (context.state === undefined) return null
+    const request = requestFromState(context.state)
+    return request === undefined
+      ? null
+      : trajectoryNode(context, request.startSeq, { kind: 'compaction', request })
+  },
+}
+
+interface SessionEndState {
+  readonly seq: number
+  readonly time: number
+}
+
+const trajectorySessionEndDefinition: ConversationNodeDefinition<SessionEndState> = {
+  kind: 'trajectory-session-end',
+  target: 'trajectory',
+  match: event => event.type === 'session/end-seed'
+    ? { id: String(event.seq), role: 'start' }
+    : null,
+  start: (_context, match) => ({ seq: match.event.seq, time: match.event.time }),
+  update: context => context.state,
+  buildViewNode: context => context.state === undefined
+    ? null
+    : trajectoryNode(context, context.state.seq, {
+        kind: 'session-end',
+        seq: context.state.seq,
+        time: context.state.time,
+      }),
+}
+
+/** Register Trajectory compaction requests and session boundaries. */
+export function registerTrajectoryCompactionDefinitions(ctx: Context): void {
+  ctx.conversationEvents.register(trajectoryCompactionDefinition)
+  ctx.conversationEvents.register(trajectorySessionEndDefinition)
+}

+ 75 - 0
packages/client/ui-trajectory/src/client/trajectory-contract.ts

@@ -0,0 +1,75 @@
+import type {
+  AssistantMessageNode, ConversationContext, ConversationLocation, ConversationNode,
+  ConversationPromptSnapshot, ConversationViewNode, PartialAssistant,
+  RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock,
+} from '@deepseek-ai/dsh-client-runtime/client'
+
+/** Request-header facts retained by the Trajectory target. */
+export interface TrajectoryRequestHeaderState {
+  readonly seq: number
+  readonly time: number
+  readonly prompt: ConversationPromptSnapshot
+  readonly change?: RequestPromptChange
+  readonly location: ConversationLocation
+}
+
+/** One independently assembled contribution to the legacy Trajectory ledger. */
+export type TrajectoryContribution =
+  | {
+    readonly kind: 'node'
+    readonly node: ConversationNode
+  }
+  | {
+    readonly kind: 'assistant'
+    readonly node?: AssistantMessageNode
+    readonly partial: PartialAssistant | null
+    readonly request?: Extract<RequestView, { purpose: 'assistant' }>
+  }
+  | {
+    readonly kind: 'tool'
+    readonly root: ToolCallBlock
+  }
+  | {
+    readonly kind: 'request-header'
+    readonly header: TrajectoryRequestHeaderState
+  }
+  | {
+    readonly kind: 'compaction'
+    readonly request: Extract<RequestView, { purpose: 'compaction' }>
+  }
+  | {
+    readonly kind: 'session-end'
+    readonly seq: number
+    readonly time: number
+  }
+  | {
+    readonly kind: 'turn-end'
+    readonly turn: number
+    readonly time: number
+    readonly error?: string
+  }
+
+/** Target envelope consumed by the Trajectory snapshot builder. */
+export interface TrajectoryConversationViewNode extends ConversationViewNode {
+  readonly target: 'trajectory'
+  readonly anchorSeq: number
+  readonly data: TrajectoryContribution
+}
+
+/** Stage-oriented Trajectory data assembled from registered business Contexts. */
+export interface TrajectorySnapshot {
+  readonly eventNodes: readonly ConversationNode[]
+  readonly contexts: readonly ConversationContext[]
+  readonly requests: readonly RequestView[]
+  readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
+  readonly interruptedNodes: readonly ConversationNode[]
+  readonly partial: PartialAssistant | null
+  readonly runningCalls: readonly RunningToolCall[]
+}
+
+declare module '@deepseek-ai/dsh-client-runtime/client' {
+  interface ConversationViewSnapshotMap {
+    /** Independently assembled data consumed by the Trajectory view. */
+    trajectory: TrajectorySnapshot
+  }
+}

+ 29 - 0
packages/client/ui-trajectory/src/client/trajectory-definition-common.ts

@@ -0,0 +1,29 @@
+import type {
+  ConversationLocation, ConversationNodeContext,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  TrajectoryContribution, TrajectoryConversationViewNode,
+} from './trajectory-contract.ts'
+
+/** Resolve the best loaded Location for one target-local Context. */
+export function trajectoryContextLocation(
+  context: ConversationNodeContext,
+): ConversationLocation {
+  return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
+}
+
+/** Wrap one contribution in the Engine-owned target envelope. */
+export function trajectoryNode(
+  context: ConversationNodeContext,
+  anchorSeq: number,
+  data: TrajectoryContribution,
+): TrajectoryConversationViewNode {
+  return {
+    key: context.key,
+    kind: context.kind,
+    id: context.id,
+    target: 'trajectory',
+    anchorSeq,
+    data,
+  }
+}

+ 114 - 0
packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts

@@ -0,0 +1,114 @@
+import type { Context } from 'cordis'
+import type {
+  ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext,
+  SteeringMessageNode, UserMessageNode,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import {
+  contextForm, contextProvenance,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type {} from '@deepseek-ai/dsh-agent/types'
+import { trajectoryNode } from './trajectory-definition-common.ts'
+
+interface InboxIdentity {
+  readonly id: string
+}
+
+interface InboxSplice {
+  readonly start: number
+  readonly removedCount?: number
+  readonly inserted: readonly InboxIdentity[]
+  readonly outcome?: 'canceled'
+}
+
+interface InboxState {
+  readonly pending: readonly InboxIdentity[]
+  readonly claimed: ReadonlySet<string>
+}
+
+type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
+
+function applySplice(
+  previous: ConversationPreviousContext<InboxState> | undefined,
+  splice: InboxSplice,
+): InboxState {
+  const pending = [...(previous?.state.pending ?? [])]
+  const claimed = new Set(previous?.state.claimed ?? [])
+  const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
+  for (const identity of splice.inserted) claimed.delete(identity.id)
+  if (splice.outcome !== 'canceled') {
+    for (const identity of removed) claimed.add(identity.id)
+  }
+  return { pending, claimed }
+}
+
+const trajectoryInboxDefinition: ConversationNodeDefinition<InboxState> = {
+  kind: 'trajectory-inbox-next-step',
+  match: event => event.type === 'agent/inbox/spliced'
+    && event.data.target === 'next-step'
+    ? { id: String(event.seq), role: 'start' }
+    : null,
+  start: (_context, match, reader) => {
+    if (match.event.type !== 'agent/inbox/spliced') {
+      throw new Error('trajectory-inbox-next-step start requires agent/inbox/spliced')
+    }
+    return applySplice(
+      reader.previous<InboxState>('trajectory-inbox-next-step'),
+      match.event.data,
+    )
+  },
+  update: context => context.state,
+  publication: () => 'none',
+}
+
+const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
+  kind: 'trajectory-input-message',
+  target: 'trajectory',
+  match: event => event.type === 'user/message'
+    ? { id: String(event.seq), role: 'start' }
+    : null,
+  start: (_context, match, reader) => {
+    if (match.event.type !== 'user/message') {
+      throw new Error('trajectory-input-message start requires user/message')
+    }
+    const event = match.event
+    if (event.data.source.kind !== 'user') {
+      return {
+        kind: 'context',
+        seq: event.seq,
+        time: event.time,
+        content: event.data.content,
+        source: event.data.source,
+        provenance: contextProvenance(event.data.source),
+        form: contextForm(event.data.source),
+      }
+    }
+    const claimed = reader.previous<InboxState>('trajectory-inbox-next-step')
+      ?.state.claimed.has(String(event.data.id)) === true
+    return claimed
+      ? {
+          kind: 'steering',
+          messageId: event.data.id,
+          seq: event.seq,
+          time: event.time,
+          content: event.data.content,
+          source: event.data.source,
+        }
+      : {
+          kind: 'user',
+          seq: event.seq,
+          time: event.time,
+          content: event.data.content,
+          source: event.data.source,
+        }
+  },
+  update: context => context.state,
+  buildViewNode: context => context.state === undefined
+    ? null
+    : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }),
+}
+
+/** Register Trajectory-owned inbox classification and message records. */
+export function registerTrajectoryMessageDefinitions(ctx: Context): void {
+  ctx.conversationEvents.register(trajectoryInboxDefinition)
+  ctx.conversationEvents.register(trajectoryMessageDefinition)
+}

+ 76 - 0
packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts

@@ -0,0 +1,76 @@
+import type { Context } from 'cordis'
+import type {
+  ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot,
+  RequestPromptChange,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import { trajectoryNode } from './trajectory-definition-common.ts'
+import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts'
+
+function requestPrompt(match: ConversationMatch): ConversationPromptSnapshot {
+  if (match.event.type !== 'request/header') {
+    throw new Error('trajectory-request-header start requires request/header')
+  }
+  const header = match.event.data.header
+  const tools: unknown = header.tools
+  return {
+    config: header.config,
+    system: header.system ?? '',
+    tools: Array.isArray(tools) ? tools as ConversationPromptSnapshot['tools'] : [],
+  }
+}
+
+function promptChange(
+  previous: ConversationPromptSnapshot | undefined,
+  prompt: ConversationPromptSnapshot,
+  match: ConversationMatch,
+): RequestPromptChange | undefined {
+  if (match.event.type !== 'request/header') return undefined
+  if (previous === undefined && match.event.data.reason !== 'initial') return undefined
+  const systemChanged = previous !== undefined && previous.system !== prompt.system
+  const toolsChanged = previous !== undefined
+    && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
+  if (previous !== undefined && !systemChanged && !toolsChanged) return undefined
+  return {
+    seq: match.event.seq,
+    time: match.event.time,
+    kind: previous === undefined
+      ? 'initial'
+      : systemChanged && toolsChanged
+        ? 'system-and-tools'
+        : systemChanged ? 'system' : 'tools',
+    ...(previous === undefined ? {} : { previous }),
+  }
+}
+
+const trajectoryRequestHeaderDefinition: ConversationNodeDefinition<TrajectoryRequestHeaderState> = {
+  kind: 'trajectory-request-header',
+  target: 'trajectory',
+  match: event => event.type === 'request/header'
+    ? { id: String(event.seq), role: 'start' }
+    : null,
+  start: (_context, match, reader) => {
+    const prompt = requestPrompt(match)
+    const previous = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')
+      ?.state.prompt
+    const change = promptChange(previous, prompt, match)
+    return {
+      seq: match.event.seq,
+      time: match.event.time,
+      prompt,
+      location: match.location,
+      ...(change === undefined ? {} : { change }),
+    }
+  },
+  update: context => context.state,
+  buildViewNode: context => context.state === undefined
+    ? null
+    : trajectoryNode(context, context.state.seq, {
+        kind: 'request-header',
+        header: context.state,
+      }),
+}
+
+/** Register Trajectory request-header facts. */
+export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void {
+  ctx.conversationEvents.register(trajectoryRequestHeaderDefinition)
+}

+ 222 - 0
packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts

@@ -0,0 +1,222 @@
+import type { Context } from 'cordis'
+import type {
+  AssistantMessageNode, ConversationNode, ConversationPromptSnapshot,
+  ConversationViewBuilder, ConversationViewDefinition, RequestView,
+  ToolCallBlock,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  TrajectoryConversationViewNode, TrajectoryRequestHeaderState,
+  TrajectorySnapshot,
+} from './trajectory-contract.ts'
+
+const EMPTY_LIST: readonly never[] = []
+const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }]
+
+/** Stable empty target used until a Session has assembled Trajectory records. */
+export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
+  eventNodes: EMPTY_LIST,
+  contexts: EMPTY_CONTEXTS,
+  requests: EMPTY_LIST,
+  callSchemas: new Map(),
+  interruptedNodes: EMPTY_LIST,
+  partial: null,
+  runningCalls: EMPTY_LIST,
+}
+
+function coordinates(
+  header: TrajectoryRequestHeaderState,
+): { turn?: number; step?: number } {
+  const location = header.location
+  if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step }
+  if (location.kind === 'turn') return { turn: location.turn.turn }
+  return {}
+}
+
+function headerFor(
+  request: Extract<RequestView, { purpose: 'assistant' }>,
+  headers: readonly TrajectoryRequestHeaderState[],
+): TrajectoryRequestHeaderState | undefined {
+  const exact = headers.findLast((header) => {
+    const location = coordinates(header)
+    return location.turn === request.turn && location.step === request.step
+  })
+  return exact ?? headers.findLast(header => header.seq < request.startSeq)
+}
+
+function applyHeader(
+  request: Extract<RequestView, { purpose: 'assistant' }>,
+  header: TrajectoryRequestHeaderState | undefined,
+): Extract<RequestView, { purpose: 'assistant' }> {
+  return header === undefined
+    ? request
+    : {
+        ...request,
+        prompt: header.prompt,
+        requestConfig: header.prompt.config,
+        ...(header.change === undefined ? {} : { promptChange: header.change }),
+      }
+}
+
+function withRequestConfig(
+  node: AssistantMessageNode,
+  prompt: ConversationPromptSnapshot | undefined,
+): AssistantMessageNode {
+  return prompt === undefined ? node : { ...node, requestConfig: prompt.config }
+}
+
+function captureSchemas(
+  block: ToolCallBlock,
+  tools: readonly ConversationPromptSnapshot['tools'][number][],
+  output: Map<string, ConversationPromptSnapshot['tools'][number]>,
+): void {
+  const name = 'kind' in block ? block.call?.name : block.name
+  const schema = name === undefined || name === null
+    ? undefined
+    : tools.find(candidate => candidate.name === name)
+  if (schema !== undefined) output.set(block.callId, schema)
+  for (const child of block.subCalls) captureSchemas(child, tools, output)
+}
+
+function interruptCompactions(
+  requests: RequestView[],
+  boundaries: readonly { seq: number; time: number }[],
+): void {
+  for (const boundary of boundaries) {
+    const index = requests.findLastIndex(request =>
+      request.purpose === 'compaction'
+      && request.startSeq < boundary.seq
+      && request.status === 'running')
+    const request = requests[index]
+    if (request?.purpose !== 'compaction') continue
+    requests[index] = {
+      ...request,
+      completedAt: boundary.time,
+      status: 'error',
+      error: 'Compaction was interrupted before completion.',
+    }
+  }
+}
+
+function applyTurnErrors(
+  requests: RequestView[],
+  endings: readonly { turn: number; time: number; error?: string }[],
+): void {
+  for (const ending of endings) {
+    if (ending.error === undefined) continue
+    const index = requests.findLastIndex(request =>
+      request.purpose === 'assistant' && request.turn === ending.turn)
+    const request = requests[index]
+    if (request?.purpose !== 'assistant') continue
+    requests[index] = {
+      ...request,
+      completedAt: request.completedAt ?? ending.time,
+      status: 'error',
+      error: ending.error,
+    }
+  }
+}
+
+/** Simple keyed adapter retaining the old Trajectory snapshot and stage layout. */
+export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
+  TrajectoryConversationViewNode,
+  TrajectorySnapshot
+> {
+  private readonly nodes = new Map<string, TrajectoryConversationViewNode>()
+  readonly empty = EMPTY_TRAJECTORY_SNAPSHOT
+
+  replace(input: {
+    readonly nodes: readonly TrajectoryConversationViewNode[]
+  }): TrajectorySnapshot {
+    this.nodes.clear()
+    for (const node of input.nodes) this.nodes.set(node.key, node)
+    return this.snapshot()
+  }
+
+  apply(input: {
+    readonly upserts: readonly TrajectoryConversationViewNode[]
+  }): TrajectorySnapshot {
+    for (const node of input.upserts) this.nodes.set(node.key, node)
+    return this.snapshot()
+  }
+
+  private snapshot(): TrajectorySnapshot {
+    const contributions = [...this.nodes.values()]
+      .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
+    const headers = contributions.flatMap(node => node.data.kind === 'request-header'
+      ? [node.data.header]
+      : [])
+    const finalized: ConversationNode[] = []
+    const requests: RequestView[] = []
+    const boundaries: { seq: number; time: number }[] = []
+    const turnEndings: { turn: number; time: number; error?: string }[] = []
+    const callSchemas = new Map<string, ConversationPromptSnapshot['tools'][number]>()
+    let partial: TrajectorySnapshot['partial'] = null
+    const runningCalls: TrajectorySnapshot['runningCalls'][number][] = []
+
+    for (const contribution of contributions) {
+      const data = contribution.data
+      if (data.kind === 'node') {
+        finalized.push(data.node)
+        continue
+      }
+      if (data.kind === 'assistant') {
+        const header = data.request === undefined ? undefined : headerFor(data.request, headers)
+        if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt))
+        if (data.partial !== null) partial = data.partial
+        if (data.request !== undefined) requests.push(applyHeader(data.request, header))
+        continue
+      }
+      if (data.kind === 'tool') {
+        if ('kind' in data.root) finalized.push(data.root)
+        else runningCalls.push(data.root)
+        const header = headers.findLast(candidate => candidate.seq < contribution.anchorSeq)
+        if (header !== undefined) captureSchemas(data.root, header.prompt.tools, callSchemas)
+        continue
+      }
+      if (data.kind === 'compaction') {
+        requests.push(data.request)
+        continue
+      }
+      if (data.kind === 'session-end') {
+        boundaries.push({ seq: data.seq, time: data.time })
+        continue
+      }
+      if (data.kind === 'turn-end') {
+        turnEndings.push({
+          turn: data.turn,
+          time: data.time,
+          ...(data.error === undefined ? {} : { error: data.error }),
+        })
+      }
+    }
+
+    requests.sort((left, right) => left.startSeq - right.startSeq)
+    interruptCompactions(requests, boundaries)
+    applyTurnErrors(requests, turnEndings)
+    finalized.sort((left, right) => left.seq - right.seq)
+    const eventNodes = finalized
+    return {
+      eventNodes,
+      contexts: [{ id: 0, nodes: eventNodes }],
+      requests,
+      callSchemas,
+      interruptedNodes: EMPTY_LIST,
+      partial,
+      runningCalls,
+    }
+  }
+}
+
+/** Trajectory target factory preserving the existing stage-oriented view model. */
+export const trajectoryViewDefinition: ConversationViewDefinition<
+  TrajectoryConversationViewNode,
+  TrajectorySnapshot
+> = {
+  target: 'trajectory',
+  create: () => new TrajectorySnapshotBuilder(),
+}
+
+/** Register the legacy-shape Trajectory target builder. */
+export function registerTrajectoryConversationView(ctx: Context): void {
+  ctx.conversationViews.register(trajectoryViewDefinition)
+}

+ 250 - 0
packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts

@@ -0,0 +1,250 @@
+import type { Context } from 'cordis'
+import type {
+  ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
+  RunningToolCall, ToolCallBlock, ToolResultNode,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type {} from '@deepseek-ai/dsh-tools/types'
+import { trajectoryNode } from './trajectory-definition-common.ts'
+
+const MAX_DEPTH = 256
+
+interface ToolState {
+  readonly rootId: string
+  readonly calls: ReadonlyMap<string, ToolCallBlock>
+  readonly children: ReadonlyMap<string, readonly string[]>
+  readonly parents: ReadonlyMap<string, string>
+}
+
+interface DispatchData {
+  readonly parentCallId: string
+  readonly subCallId: string
+  readonly name: string
+  readonly arguments: unknown
+  readonly isError?: boolean
+  readonly content?: ToolResultNode['content']
+}
+
+function rootCall(match: ConversationMatch): RunningToolCall {
+  if (match.event.type !== 'tool/call') {
+    throw new Error('trajectory-tool-call start requires tool/call')
+  }
+  return {
+    callId: String(match.event.data.callId),
+    name: match.event.data.name,
+    argsRaw: match.event.data.arguments,
+    turn: match.event.data.turn,
+    step: match.event.data.step,
+    time: match.event.time,
+    callView: match.view?.for === 'call' ? match.view.view : null,
+    subCalls: [],
+  }
+}
+
+function rootResult(
+  match: ConversationMatch,
+  previous?: RunningToolCall,
+): ToolResultNode | undefined {
+  if (match.event.type !== 'tool/result') return undefined
+  const result = match.event.data.message.content[0]
+  return {
+    kind: 'tool-result',
+    seq: match.event.seq,
+    time: match.event.time,
+    callId: String(match.event.data.message.source.callId),
+    call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
+    callTime: previous?.time ?? null,
+    content: result.content,
+    isError: result.isError === true,
+    ...(match.event.data.error === undefined ? {} : { error: match.event.data.error }),
+    meta: match.event.data.meta,
+    callView: previous?.callView ?? null,
+    resultView: match.view?.for === 'result' ? match.view.view : null,
+    subCalls: [],
+  }
+}
+
+function locationTurn(match: ConversationMatch): number {
+  return match.location.kind === 'step' || match.location.kind === 'turn'
+    ? match.location.turn.turn
+    : 0
+}
+
+function locationStep(match: ConversationMatch): number {
+  return match.location.kind === 'step' ? match.location.step.step : 0
+}
+
+function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
+  return {
+    callId: data.subCallId,
+    name: data.name,
+    argsRaw: JSON.stringify(data.arguments),
+    turn: locationTurn(match),
+    step: locationStep(match),
+    time: match.event.time,
+    callView: null,
+    subCalls: [],
+  }
+}
+
+function childResult(
+  match: ConversationMatch,
+  data: DispatchData,
+  previous?: ToolCallBlock,
+): ToolResultNode {
+  return {
+    kind: 'tool-result',
+    seq: match.event.seq,
+    time: match.event.time,
+    callId: data.subCallId,
+    call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
+    callTime: previous === undefined || 'kind' in previous ? null : previous.time,
+    content: data.content ?? [],
+    isError: data.isError === true,
+    callView: null,
+    resultView: null,
+    subCalls: [],
+  }
+}
+
+function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
+  if (parent === child || state.parents.has(child)) return false
+  let cursor: string | undefined = parent
+  for (let depth = 0; cursor !== undefined && depth <= MAX_DEPTH; depth++) {
+    if (cursor === child) return false
+    cursor = state.parents.get(cursor)
+  }
+  return cursor === undefined
+}
+
+function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
+  const event = match.event
+  if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state
+  const data = event.data
+  const parentId = String(data.parentCallId)
+  const childId = String(data.subCallId)
+  const siblings = state.children.get(parentId) ?? []
+  const index = siblings.indexOf(childId)
+  if (index < 0 && !acceptsEdge(state, parentId, childId)) return state
+  if (event.type === 'tool/code-dispatch-start' && index >= 0) return state
+
+  const calls = new Map(state.calls)
+  calls.set(childId, event.type === 'tool/code-dispatch-start'
+    ? childCall(match, data)
+    : childResult(match, data, calls.get(childId)))
+  if (index >= 0) return { ...state, calls }
+  const children = new Map(state.children)
+  children.set(parentId, [...siblings, childId])
+  const parents = new Map(state.parents)
+  parents.set(childId, parentId)
+  return { ...state, calls, children, parents }
+}
+
+function interruption(
+  context: ConversationNodeContext<ToolState>,
+): { seq: number; time: number } | undefined {
+  const location = context.start?.location
+  if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
+  if ((location?.kind === 'step' || location?.kind === 'turn')
+    && location.turn.status === 'closed') return location.turn.end
+  return undefined
+}
+
+function projectCall(
+  state: ToolState,
+  callId: string,
+  interruptedAt: { seq: number; time: number } | undefined,
+  visited = new Set<string>(),
+  depth = 1,
+): ToolCallBlock | undefined {
+  const block = state.calls.get(callId)
+  if (block === undefined) return undefined
+  if (visited.has(callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] }
+  const nextVisited = new Set(visited)
+  nextVisited.add(callId)
+  const subCalls = (state.children.get(callId) ?? [])
+    .flatMap((childId) => {
+      const child = projectCall(state, childId, interruptedAt, nextVisited, depth + 1)
+      return child === undefined ? [] : [child]
+    })
+  if ('kind' in block || interruptedAt === undefined) return { ...block, subCalls }
+  return {
+    kind: 'tool-result',
+    seq: interruptedAt.seq - 0.8,
+    time: interruptedAt.time,
+    callId: block.callId,
+    call: { name: block.name, argsRaw: block.argsRaw },
+    callTime: block.time,
+    content: [],
+    isError: true,
+    error: { name: 'Interrupted', code: 'interrupted' },
+    callView: block.callView,
+    resultView: null,
+    subCalls,
+  }
+}
+
+function fallbackState(context: ConversationNodeContext<ToolState>): ToolState | undefined {
+  const resultMatch = context.matches.find(match => match.event.type === 'tool/result')
+  const root = resultMatch === undefined ? undefined : rootResult(resultMatch)
+  if (root === undefined) return undefined
+  let state: ToolState = {
+    rootId: root.callId,
+    calls: new Map([[root.callId, root]]),
+    children: new Map(),
+    parents: new Map(),
+  }
+  for (const match of context.matches) state = updateDispatch(state, match)
+  return state
+}
+
+/** Trajectory-owned root Tool lifecycle with nested Code Dispatch calls. */
+const trajectoryToolDefinition: ConversationNodeDefinition<ToolState> = {
+  kind: 'trajectory-tool-call',
+  target: 'trajectory',
+  match: (event) => {
+    if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
+    if (event.type === 'tool/result') {
+      return { id: String(event.data.message.source.callId), role: 'update' }
+    }
+    if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
+      const rootCallId: unknown = event.data.rootCallId
+      return typeof rootCallId === 'string' && rootCallId !== ''
+        ? { id: rootCallId, role: 'update' }
+        : null
+    }
+    return null
+  },
+  start: (_context, match) => {
+    const root = rootCall(match)
+    return {
+      rootId: root.callId,
+      calls: new Map([[root.callId, root]]),
+      children: new Map(),
+      parents: new Map(),
+    }
+  },
+  update: (context, match) => {
+    if (match.event.type !== 'tool/result') return updateDispatch(context.state, match)
+    const previous = context.state.calls.get(context.state.rootId)
+    const running = previous !== undefined && !('kind' in previous) ? previous : undefined
+    const result = rootResult(match, running)
+    if (result === undefined) return context.state
+    const calls = new Map(context.state.calls)
+    calls.set(context.state.rootId, result)
+    return { ...context.state, calls }
+  },
+  buildViewNode: (context) => {
+    const state = context.state ?? fallbackState(context)
+    if (state === undefined) return null
+    const root = projectCall(state, state.rootId, interruption(context))
+    if (root === undefined) return null
+    const anchorSeq = context.start?.event.seq
+      ?? ('kind' in root ? root.seq : context.matches[0]?.event.seq ?? 0)
+    return trajectoryNode(context, anchorSeq, { kind: 'tool', root })
+  },
+}
+
+/** Register the Trajectory Tool lifecycle. */
+export function registerTrajectoryToolDefinition(ctx: Context): void {
+  ctx.conversationEvents.register(trajectoryToolDefinition)
+}

+ 11 - 5
packages/client/ui-trajectory/tests/client-bundle.spec.ts

@@ -10,7 +10,9 @@ import { readFileSync } from 'node:fs'
 import { resolve } from 'node:path'
 import { resolve } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it } from 'vitest'
 import { afterEach, describe, expect, it } from 'vitest'
-import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import {
+  ConversationEventRegistry, ConversationViewRegistry, SlotsService,
+} from '@deepseek-ai/dsh-client-runtime/client'
 
 
 const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
 const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
 
 
@@ -61,21 +63,25 @@ describe('tsdown client artifact', () => {
     const { handoff, surface } = await loadArtifact()
     const { handoff, surface } = await loadArtifact()
     expect(handoff.id).toBe(PLUGIN_ID)
     expect(handoff.id).toBe(PLUGIN_ID)
     expect(surface.apply).toBeTypeOf('function')
     expect(surface.apply).toBeTypeOf('function')
-    expect(surface.inject).toEqual(['slots', 'sessionHistory'])
+    expect(surface.inject).toEqual([
+      'slots', 'conversationEvents', 'conversationViews', 'sessions',
+    ])
   })
   })
 
 
   it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
   it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
     const { surface } = await loadArtifact()
     const { surface } = await loadArtifact()
     const ctx = new Context()
     const ctx = new Context()
     const slots = new SlotsService(ctx)
     const slots = new SlotsService(ctx)
+    await ctx.plugin(ConversationEventRegistry).await()
+    await ctx.plugin(ConversationViewRegistry).await()
     // The conversation entry's role: the ring must be declared before riders land.
     // The conversation entry's role: the ring must be declared before riders land.
     slots.register({
     slots.register({
       name: 'root',
       name: 'root',
       children: { 'conversation.view': { kind: 'list', scope: 'session' } },
       children: { 'conversation.view': { kind: 'list', scope: 'session' } },
     }, (_p: { renderSlot?: unknown }) => null)
     }, (_p: { renderSlot?: unknown }) => null)
-    // The plugin reads sessionHistory for its per-session history source;
-    // slot availability is tracked by slots.inject.
-    ctx.provide('sessionHistory', {})
+    // Paging is session-owned; this registration-only probe never renders the
+    // entry, so the binding stays deliberately empty.
+    ctx.provide('sessions', { binding: () => undefined })
     const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
     const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
     await fiber.await()
     await fiber.await()
     expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
     expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])

+ 74 - 67
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -13,12 +13,15 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
 import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import {
+  ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore,
+  EMPTY_CHAT_SNAPSHOT,
+} from '@deepseek-ai/dsh-client-runtime/client'
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import type {
 import type {
-  ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
-  SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
+  ConversationSnapshot, RequestView,
+  SessionId, SessionListState, SnapshotStore, WorkspaceListState,
 } from '@deepseek-ai/dsh-client-runtime/client'
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import {
 import {
@@ -35,9 +38,11 @@ import {
   TrajectoryView, type TrajectoryViewInjected,
   TrajectoryView, type TrajectoryViewInjected,
 } from '../src/client/TrajectoryView.tsx'
 } from '../src/client/TrajectoryView.tsx'
 import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
 import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
+import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
 import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
 import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
 
 
 const SID = 's1' as SessionId
 const SID = 's1' as SessionId
+const sessionSnapshots = new WeakMap<SlotsService, SnapshotStore<ConversationSnapshot>>()
 const tConversation: ConversationSessionHeaderProps['t'] =
 const tConversation: ConversationSessionHeaderProps['t'] =
   key => (conversationZh as Record<string, string>)[key] ?? key
   key => (conversationZh as Record<string, string>)[key] ?? key
 
 
@@ -67,37 +72,55 @@ const NODES = [
 
 
 function historySnapshot(
 function historySnapshot(
   nodes: ConversationSnapshot['nodes'],
   nodes: ConversationSnapshot['nodes'],
-  inspection: Partial<SessionHistoryInspection> = {},
-): SessionHistorySnapshot {
+  inspection: Partial<TrajectorySnapshot> = {},
+): ConversationSnapshot {
+  const trajectory: TrajectorySnapshot = {
+    eventNodes: nodes,
+    contexts: [{ id: 0, nodes }],
+    requests: [],
+    callSchemas: new Map(),
+    interruptedNodes: [],
+    partial: null,
+    runningCalls: [],
+    ...inspection,
+  }
   return {
   return {
-    state: 'ready',
-    error: null,
+    sessionId: SID,
+    views: {
+      get: target => target === 'trajectory' ? trajectory : undefined,
+    } as ConversationSnapshot['views'],
+    chat: EMPTY_CHAT_SNAPSHOT,
+    nodes,
+    turnTimings: new Map(),
+    turnEnds: new Map(),
+    partial: trajectory.partial,
+    runningCalls: trajectory.runningCalls,
+    pending: [],
+    queue: [],
+    running: false,
+    subagent: null,
+    composerPhase: 'active',
+    removed: false,
+    openState: 'open',
+    openError: null,
     hasMore: false,
     hasMore: false,
-    baseSeq: nodes[0]?.seq ?? 0,
-    inspection: {
-      eventNodes: nodes,
-      contexts: [{ id: 0, nodes }],
-      requests: [],
-      callSchemas: new Map(),
-      interruptedNodes: [],
-      partial: null,
-      runningCalls: [],
-      ...inspection,
-    },
+    loadingOlder: false,
+    promptError: null,
+    blank: nodes.length === 0,
+    lastAgentError: null,
   }
   }
 }
 }
 
 
 function standaloneHistory(
 function standaloneHistory(
-  snapshot: SessionHistorySnapshot,
+  snapshot: ConversationSnapshot,
 ): Pick<
 ): Pick<
   ComponentProps<typeof TrajectoryView>,
   ComponentProps<typeof TrajectoryView>,
-  'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
+  'useSession' | 'loadOlder'
 > {
 > {
   const store = createSnapshotStore(snapshot)
   const store = createSnapshotStore(snapshot)
   return {
   return {
-    useHistory: bindSnapshotSelector(store),
-    loadHistoryTail: () => Promise.resolve(),
-    loadOlderHistory: () => Promise.resolve(false),
+    useSession: bindSnapshotSelector(store),
+    loadOlder: () => Promise.resolve(false),
   }
   }
 }
 }
 
 
@@ -112,11 +135,8 @@ function standaloneDuration(): Pick<
 }
 }
 
 
 function fakeSession(nodes: ConversationSnapshot['nodes']) {
 function fakeSession(nodes: ConversationSnapshot['nodes']) {
-  const store = createSnapshotStore({
-    nodes, pending: [], partial: null,
-    runningCalls: [] as ConversationSnapshot['runningCalls'],
-  })
-  return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
+  const store = createSnapshotStore(historySnapshot(nodes))
+  return { store, useSession: bindSnapshotSelector(store) as UseSession<ConversationSnapshot> }
 }
 }
 
 
 /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
 /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
@@ -149,16 +169,19 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
 async function bench(snapshot = historySnapshot(NODES)) {
 async function bench(snapshot = historySnapshot(NODES)) {
   const ctx = new Context()
   const ctx = new Context()
   const slots = new SlotsService(ctx)
   const slots = new SlotsService(ctx)
-  const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
-  const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
-  const historyStore = createSnapshotStore(snapshot)
-  const history: SessionHistoryFace = {
-    sessionId: SID,
-    getSnapshot: () => historyStore.getSnapshot(),
-    subscribe: listener => historyStore.subscribe(listener),
-    loadTail: loadHistoryTail,
-    loadOlder: loadOlderHistory,
+  const loadOlder = vi.fn(() => Promise.resolve())
+  const sessionStore = createSnapshotStore(snapshot)
+  const session = {
+    getSnapshot: () => sessionStore.getSnapshot(),
+    subscribe: (listener: () => void) => sessionStore.subscribe(listener),
+    loadOlder,
   }
   }
+  await ctx.plugin(ConversationEventRegistry).await()
+  await ctx.plugin(ConversationViewRegistry).await()
+  ctx.provide('sessions', {
+    binding: () => ({ session }),
+  })
+  sessionSnapshots.set(slots, sessionStore)
   // The conversation entry's role: declare the ring, then seed the chat entry.
   // The conversation entry's role: declare the ring, then seed the chat entry.
   slots.register({
   slots.register({
     name: 'root',
     name: 'root',
@@ -167,10 +190,9 @@ async function bench(snapshot = historySnapshot(NODES)) {
   const chatBody = vi.fn(() => <div data-testid="chat-body" />)
   const chatBody = vi.fn(() => <div data-testid="chat-body" />)
   slots.register(
   slots.register(
     { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
     { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
-  ctx.provide('sessionHistory', { source: () => history })
   const fiber = ctx.plugin({ inject: [...inject], apply })
   const fiber = ctx.plugin({ inject: [...inject], apply })
   await fiber.await()
   await fiber.await()
-  return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
+  return { ctx, slots, fiber, loadOlder }
 }
 }
 
 
 /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
 /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -181,13 +203,8 @@ function tabsOf(slots: SlotsService): ViewTab[] {
 
 
 /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
 /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
 function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
 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'],
-  })
-  const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
+  const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes))
+  const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession<ConversationSnapshot>
   const chat = createChatStore().create()
   const chat = createChatStore().create()
   const views = {
   const views = {
     list: () => tabsOf(slots),
     list: () => tabsOf(slots),
@@ -215,10 +232,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
       ? (() => {
       ? (() => {
         const trajectory = injected as TrajectoryViewInjected
         const trajectory = injected as TrajectoryViewInjected
         return {
         return {
-          loadHistoryTail: trajectory.loadHistoryTail,
-          loadOlderHistory: trajectory.loadOlderHistory,
+          loadOlder: trajectory.loadOlder,
           setActualDuration: trajectory.setActualDuration,
           setActualDuration: trajectory.setActualDuration,
-          useHistory: bindSnapshotSelector(trajectory.hooks.history),
           useDuration: bindSnapshotSelector(trajectory.hooks.duration),
           useDuration: bindSnapshotSelector(trajectory.hooks.duration),
         }
         }
       })()
       })()
@@ -322,13 +337,9 @@ describe('tab switching in ConversationRoot', () => {
     fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
     fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
     expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
     expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
     expect(screen.queryByTestId('chat-body')).toBeNull()
     expect(screen.queryByTestId('chat-body')).toBeNull()
-    await vi.waitFor(() => {
-      expect(b.loadHistoryTail).toHaveBeenCalledOnce()
-    })
-    const signal = b.loadHistoryTail.mock.calls[0]?.[0]
-    expect(signal?.aborted).toBe(false)
+    expect(b.loadOlder).not.toHaveBeenCalled()
     fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
     fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
-    expect(signal?.aborted).toBe(true)
+    expect(b.loadOlder).not.toHaveBeenCalled()
   })
   })
 
 
   it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
   it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
@@ -1162,9 +1173,8 @@ describe('TrajectoryView branches', () => {
       <TrajectoryView
       <TrajectoryView
         {...standaloneProps([])}
         {...standaloneProps([])}
         {...standaloneDuration()}
         {...standaloneDuration()}
-        useHistory={bindSnapshotSelector(store)}
-        loadHistoryTail={vi.fn(() => Promise.resolve())}
-        loadOlderHistory={vi.fn(() => Promise.resolve(false))}
+        useSession={bindSnapshotSelector(store)}
+        loadOlder={vi.fn(() => Promise.resolve(false))}
       />,
       />,
     )
     )
 
 
@@ -1196,9 +1206,8 @@ describe('TrajectoryView branches', () => {
       <TrajectoryView
       <TrajectoryView
         {...standaloneProps([])}
         {...standaloneProps([])}
         {...standaloneDuration()}
         {...standaloneDuration()}
-        useHistory={bindSnapshotSelector(store)}
-        loadHistoryTail={vi.fn(() => Promise.resolve())}
-        loadOlderHistory={vi.fn(() => Promise.resolve(false))}
+        useSession={bindSnapshotSelector(store)}
+        loadOlder={vi.fn(() => Promise.resolve(false))}
       />,
       />,
     )
     )
     const row = screen.getByRole('row', { name: /stable rewind response/ })
     const row = screen.getByRole('row', { name: /stable rewind response/ })
@@ -1225,9 +1234,8 @@ describe('TrajectoryView branches', () => {
       <TrajectoryView
       <TrajectoryView
         {...standaloneProps([])}
         {...standaloneProps([])}
         {...standaloneDuration()}
         {...standaloneDuration()}
-        useHistory={bindSnapshotSelector(store)}
-        loadHistoryTail={vi.fn(() => Promise.resolve())}
-        loadOlderHistory={vi.fn(() => Promise.resolve(false))}
+        useSession={bindSnapshotSelector(store)}
+        loadOlder={vi.fn(() => Promise.resolve(false))}
       />,
       />,
     )
     )
     fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
     fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
@@ -1274,9 +1282,8 @@ describe('TrajectoryView branches', () => {
       <TrajectoryView
       <TrajectoryView
         {...standaloneProps([])}
         {...standaloneProps([])}
         {...standaloneDuration()}
         {...standaloneDuration()}
-        useHistory={bindSnapshotSelector(store)}
-        loadHistoryTail={vi.fn(() => Promise.resolve())}
-        loadOlderHistory={vi.fn(() => Promise.resolve(false))}
+        useSession={bindSnapshotSelector(store)}
+        loadOlder={vi.fn(() => Promise.resolve(false))}
       />,
       />,
     )
     )