diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts
index e1d3a5cc17..1f48a4711d 100644
--- a/packages/client/ui-trajectory/src/client/index.ts
+++ b/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 { createTrajectoryDurationStore } from './duration-store.ts'
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
@@ -20,17 +26,29 @@ export const inject = ['slots', 'sessionHistory']
*/
export function apply(ctx: Context): void {
const duration = createTrajectoryDurationStore()
+ registerTrajectoryMessageDefinitions(ctx)
+ registerTrajectoryRequestHeaderDefinition(ctx)
+ registerTrajectoryAssistantDefinition(ctx)
+ registerTrajectoryToolDefinition(ctx)
+ registerTrajectoryCompactionDefinitions(ctx)
+ registerTrajectoryConversationView(ctx)
ctx.slots.inject('conversation.view', () => ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
order: 10,
label: 'Trajectory',
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 {
- 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) },
}
},
diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
new file mode 100644
index 0000000000..8f0d9ff430
--- /dev/null
+++ b/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
,
+): { 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 | 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,
+): 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 | 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 = {
+ 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 = {
+ 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)
+}
diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts
new file mode 100644
index 0000000000..65bab06059
--- /dev/null
+++ b/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[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[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 | 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 = {
+ 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 = {
+ 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)
+}
diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts
new file mode 100644
index 0000000000..e261eeb9fc
--- /dev/null
+++ b/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
+ }
+ | {
+ readonly kind: 'tool'
+ readonly root: ToolCallBlock
+ }
+ | {
+ readonly kind: 'request-header'
+ readonly header: TrajectoryRequestHeaderState
+ }
+ | {
+ readonly kind: 'compaction'
+ readonly request: Extract
+ }
+ | {
+ 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
+ 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
+ }
+}
diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts
new file mode 100644
index 0000000000..d11034b9b9
--- /dev/null
+++ b/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,
+ }
+}
diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts
new file mode 100644
index 0000000000..5f6b203e28
--- /dev/null
+++ b/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
+}
+
+type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
+
+function applySplice(
+ previous: ConversationPreviousContext | 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 = {
+ 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('trajectory-inbox-next-step'),
+ match.event.data,
+ )
+ },
+ update: context => context.state,
+ publication: () => 'none',
+}
+
+const trajectoryMessageDefinition: ConversationNodeDefinition = {
+ 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('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)
+}
diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts
new file mode 100644
index 0000000000..a6ec4e4597
--- /dev/null
+++ b/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 = {
+ 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('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)
+}
diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts
new file mode 100644
index 0000000000..613cd1e750
--- /dev/null
+++ b/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,
+ 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,
+ header: TrajectoryRequestHeaderState | undefined,
+): Extract {
+ 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,
+): 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()
+ 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()
+ 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)
+}
diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts
new file mode 100644
index 0000000000..353d89070b
--- /dev/null
+++ b/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
+ readonly children: ReadonlyMap
+ readonly parents: ReadonlyMap
+}
+
+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,
+): { 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(),
+ 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 | 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 = {
+ 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)
+}
diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts
index 511cf03b5c..902363b719 100644
--- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts
+++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts
@@ -10,7 +10,9 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
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'
@@ -61,21 +63,25 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
- expect(surface.inject).toEqual(['slots', '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 () => {
const { surface } = await loadArtifact()
const ctx = new Context()
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.
slots.register({
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_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 })
await fiber.await()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx
index 26adf85ba8..ab3f23d829 100644
--- a/packages/client/ui-trajectory/tests/views.spec.tsx
+++ b/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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import {
+ ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore,
+ EMPTY_CHAT_SNAPSHOT,
+} from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
- ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
- SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
+ ConversationSnapshot, RequestView,
+ SessionId, SessionListState, SnapshotStore, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
@@ -35,9 +38,11 @@ import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
+import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
+const sessionSnapshots = new WeakMap>()
const tConversation: ConversationSessionHeaderProps['t'] =
key => (conversationZh as Record)[key] ?? key
@@ -67,37 +72,55 @@ const NODES = [
function historySnapshot(
nodes: ConversationSnapshot['nodes'],
- inspection: Partial = {},
-): SessionHistorySnapshot {
+ inspection: Partial = {},
+): ConversationSnapshot {
+ const trajectory: TrajectorySnapshot = {
+ eventNodes: nodes,
+ contexts: [{ id: 0, nodes }],
+ requests: [],
+ callSchemas: new Map(),
+ interruptedNodes: [],
+ partial: null,
+ runningCalls: [],
+ ...inspection,
+ }
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,
- 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(
- snapshot: SessionHistorySnapshot,
+ snapshot: ConversationSnapshot,
): Pick<
ComponentProps,
- 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
+ 'useSession' | 'loadOlder'
> {
const store = createSnapshotStore(snapshot)
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']) {
- const store = createSnapshotStore({
- nodes, pending: [], partial: null,
- runningCalls: [] as ConversationSnapshot['runningCalls'],
- })
- return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
+ const store = createSnapshotStore(historySnapshot(nodes))
+ return { store, useSession: bindSnapshotSelector(store) as UseSession }
}
/** 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)) {
const ctx = new Context()
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.
slots.register({
name: 'root',
@@ -167,10 +190,9 @@ async function bench(snapshot = historySnapshot(NODES)) {
const chatBody = vi.fn(() => )
slots.register(
{ 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 })
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). */
@@ -181,13 +203,8 @@ function tabsOf(slots: SlotsService): ViewTab[] {
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
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
+ const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes))
+ const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession
const chat = createChatStore().create()
const views = {
list: () => tabsOf(slots),
@@ -215,10 +232,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
? (() => {
const trajectory = injected as TrajectoryViewInjected
return {
- loadHistoryTail: trajectory.loadHistoryTail,
- loadOlderHistory: trajectory.loadOlderHistory,
+ loadOlder: trajectory.loadOlder,
setActualDuration: trajectory.setActualDuration,
- useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
}
})()
@@ -322,13 +337,9 @@ describe('tab switching in ConversationRoot', () => {
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
- await vi.waitFor(() => {
- expect(b.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' }))
- 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 () => {
@@ -1162,9 +1173,8 @@ describe('TrajectoryView branches', () => {
Promise.resolve())}
- loadOlderHistory={vi.fn(() => Promise.resolve(false))}
+ useSession={bindSnapshotSelector(store)}
+ loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)
@@ -1196,9 +1206,8 @@ describe('TrajectoryView branches', () => {
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/ })
@@ -1225,9 +1234,8 @@ describe('TrajectoryView branches', () => {
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/ }))
@@ -1274,9 +1282,8 @@ describe('TrajectoryView branches', () => {
Promise.resolve())}
- loadOlderHistory={vi.fn(() => Promise.resolve(false))}
+ useSession={bindSnapshotSelector(store)}
+ loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)
From c828d38f5193c775132dbc300e8bf582f23d6eb4 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 10 Aug 2026 18:38:50 +0800
Subject: [PATCH 35/59] refactor(client-runtime): remove legacy history fold
---
.../src/client/contract/session-history.ts | 43 --
.../client/session-history/history-fold.ts | 428 -----------------
.../src/client/session-history/service.ts | 66 ---
.../src/client/session-history/source.ts | 432 ------------------
.../runtime/src/client/sessions/history.ts | 121 -----
.../src/client/sessions/request-inspection.ts | 328 +------------
.../client/runtime/tests/history-fold.spec.ts | 232 ----------
.../runtime/tests/request-inspection.spec.ts | 319 -------------
.../tests/session-history-source.spec.ts | 180 --------
scripts/gen-cordis-catalog.ts | 1 -
10 files changed, 4 insertions(+), 2146 deletions(-)
delete mode 100644 packages/client/runtime/src/client/contract/session-history.ts
delete mode 100644 packages/client/runtime/src/client/session-history/history-fold.ts
delete mode 100644 packages/client/runtime/src/client/session-history/service.ts
delete mode 100644 packages/client/runtime/src/client/session-history/source.ts
delete mode 100644 packages/client/runtime/src/client/sessions/history.ts
delete mode 100644 packages/client/runtime/tests/history-fold.spec.ts
delete mode 100644 packages/client/runtime/tests/request-inspection.spec.ts
delete mode 100644 packages/client/runtime/tests/session-history-source.spec.ts
diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts
deleted file mode 100644
index a48e89585e..0000000000
--- a/packages/client/runtime/src/client/contract/session-history.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type {
- RpcError, SessionId,
-} from '@deepseek-ai/dsh-client-connection/client'
-import type { SessionHistoryInspection } from '../sessions/history.ts'
-import type { ObservableSnapshot } from './store.ts'
-
-/** Observable state of one independently loaded session history ledger. */
-export interface SessionHistorySnapshot {
- state: 'cold' | 'loading' | 'ready' | 'error'
- error: RpcError | null
- hasMore: boolean
- /** Absolute sequence of the first loaded raw event, or zero for an empty window. */
- baseSeq: number
- inspection: SessionHistoryInspection
-}
-
-/** Read-only history source addressed by session id. */
-export interface SessionHistoryFace
- extends ObservableSnapshot {
- readonly sessionId: SessionId
- /**
- * Load the current tail without reading older pages.
- * @param signal - Consumer lifetime.
- * @returns When the tail is ready or loading fails.
- */
- loadTail(signal?: AbortSignal): Promise
- /**
- * Prepend one older page when the current window has a predecessor.
- * @param signal - Consumer lifetime.
- * @returns Whether the loaded window advanced.
- */
- loadOlder(signal?: AbortSignal): Promise
-}
-
-/** Runtime service resolving independent history sources. */
-export interface ISessionHistory {
- /**
- * Resolve the identity-stable source for a session.
- * @param sessionId - Host session identity.
- * @returns The source owned outside Session and SessionManager.
- */
- source(sessionId: SessionId): SessionHistoryFace
-}
diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts
deleted file mode 100644
index 42aaa37027..0000000000
--- a/packages/client/runtime/src/client/session-history/history-fold.ts
+++ /dev/null
@@ -1,428 +0,0 @@
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import {
- SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
-} from '@deepseek-ai/dsh-session/surface'
-import type {
- HistoryEntry, ToolCallView, ToolResultView,
-} from '@deepseek-ai/dsh-client-connection/client'
-import type {
- AssistantRequestConfig, AssistantTiming, ConversationNode,
- PartialAssistant, RunningToolCall,
-} from '../sessions/conversation.ts'
-import { toAssistantBlocks } from '../sessions/conversation.ts'
-import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
-import { SteeringHistory } from '../sessions/steering-history.ts'
-import type {
- ConversationContext, ConversationContextOriginKind,
-} from '../sessions/conversation-context.ts'
-import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
-import { PartialAccumulator } from '../sessions/partial.ts'
-import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
-import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
-import { ToolCallTree } from '../sessions/tool-call-tree.ts'
-
-interface CallIndexEntry {
- name: string
- argsRaw: string
- time: number
- callView: ToolCallView | null
-}
-
-interface FoldedContext {
- generation: number
- nodes: readonly number[]
- originSeq?: number
-}
-
-/** Immutable conversation projections derived only from the history source. */
-export interface ConversationHistoryProjection {
- eventNodes: readonly ConversationNode[]
- contexts: readonly ConversationContext[]
- interruptedNodes: readonly ConversationNode[]
- partial: PartialAssistant | null
- runningCalls: readonly RunningToolCall[]
-}
-
-function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
- if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
- return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
-}
-
-function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
- if (event?.type !== 'user/message') return 'rewrite'
- const source = event.data.source
- if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
- if (source.plugin === 'compact') return 'compaction'
- if (source.plugin === 'rewind') return 'rewind'
- }
- return 'rewrite'
-}
-
-function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
- const replay: SessionEvent[] = []
- const originalSeqs: number[] = []
- const rebasedSeqByOriginal = new Map()
- const surface = new SurfaceManager(replay)
- const contexts: FoldedContext[] = []
- let generation = 0
- let originSeq: number | undefined
- const originalNodes = () => surface.nodes.map((seq) => {
- const original = originalSeqs[seq]
- if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
- return original
- })
- for (const event of events) {
- if (!isSurfaceEvent(event)) continue
- if (event.surfaceOp !== 'append') {
- contexts.push({
- generation,
- nodes: originalNodes(),
- ...(originSeq === undefined ? {} : { originSeq }),
- })
- generation++
- originSeq = event.seq
- }
- const rebasedSeq = replay.length
- const {
- sourceEventSeqs: rawSources,
- ...eventWithoutSources
- } = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
- const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
- const rebased = rebasedSeqByOriginal.get(seq)
- return rebased === undefined ? [] : [rebased]
- })
- const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
- ? undefined
- : mappedSourceEventSeqs
- const surfaceOp = event.surfaceOp === 'append'
- ? event.surfaceOp
- : {
- ...event.surfaceOp,
- start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
- end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
- }
- originalSeqs.push(event.seq)
- rebasedSeqByOriginal.set(event.seq, rebasedSeq)
- replay.push({
- ...eventWithoutSources,
- seq: rebasedSeq,
- surfaceOp,
- ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
- } as SessionEvent)
- }
- contexts.push({
- generation,
- nodes: originalNodes(),
- ...(originSeq === undefined ? {} : { originSeq }),
- })
- return contexts
-}
-
-// History projection owns its node mapping so Chat's live adapter remains free
-// of inspection metadata and lifecycle coupling.
-/* jscpd:ignore-start */
-function materializeNode(
- event: SessionEvent,
- callIndex: ReadonlyMap,
- resultView: ToolResultView | null,
- assistantTiming: AssistantTiming | undefined,
- requestConfig: AssistantRequestConfig | undefined,
- steering: boolean,
-): ConversationNode {
- switch (event.type) {
- case 'user/message':
- 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),
- }
- }
- if (steering) {
- return {
- kind: 'steering', messageId: event.data.id,
- seq: event.seq, time: event.time,
- content: event.data.content, source: event.data.source,
- }
- }
- return {
- kind: 'user', seq: event.seq, time: event.time,
- content: event.data.content, source: event.data.source,
- }
- case 'assistant/message':
- return {
- kind: 'assistant', seq: event.seq, time: event.time,
- turn: event.data.turn, step: event.data.step,
- blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
- provenance: {
- provider: event.data.message.source.provider,
- model: event.data.message.source.model,
- },
- ...(requestConfig === undefined ? {} : { requestConfig }),
- ...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
- }
- case 'tool/result': {
- const result = event.data.message.content[0]
- const callId = String(event.data.message.source.callId)
- const call = callIndex.get(callId)
- return {
- kind: 'tool-result', seq: event.seq, time: event.time,
- callId,
- call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
- callTime: call?.time ?? null,
- content: result.content, isError: result.isError === true,
- ...(event.data.error === undefined ? {} : { error: event.data.error }),
- meta: event.data.meta,
- callView: call?.callView ?? null,
- resultView,
- subCalls: [],
- }
- }
- default:
- return {
- kind: 'unknown', seq: event.seq, time: event.time,
- type: event.type, data: (event as { data?: unknown }).data,
- }
- }
-}
-/* jscpd:ignore-end */
-
-interface TransientProjection extends Pick<
- ConversationHistoryProjection,
- 'interruptedNodes' | 'partial' | 'runningCalls'
-> {
- toolCallTree: ToolCallTree
-}
-
-function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
- let partial: PartialAccumulator | null = null
- const openCalls = new Map()
- const interruptedNodes: ConversationNode[] = []
- const toolCallTree = new ToolCallTree()
-
- for (const entry of entries) {
- const { event } = entry
- if (toolCallTree.apply(event)) continue
- switch (event.type) {
- case 'assistant/chunk': {
- const { turn, step, chunk } = event.data
- if (partial === null || partial.turn !== turn || partial.step !== step) {
- partial = new PartialAccumulator(turn, step)
- }
- partial.push(chunk)
- break
- }
- case 'assistant/message':
- if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
- break
- case 'tool/call':
- // History reconstructs its own in-flight index; this intentionally
- // mirrors the published Chat node shape, not Chat's mutable state.
- /* jscpd:ignore-start */
- openCalls.set(String(event.data.callId), {
- callId: String(event.data.callId),
- name: event.data.name,
- argsRaw: event.data.arguments,
- turn: event.data.turn,
- step: event.data.step,
- time: event.time,
- callView: entry.view?.for === 'call' ? entry.view.view : null,
- subCalls: [],
- })
- /* jscpd:ignore-end */
- break
- case 'tool/result':
- openCalls.delete(String(event.data.message.source.callId))
- break
- case 'turn/end': {
- if (partial !== null && partial.turn === event.data.turn) {
- const { blocks } = partial.toPartial()
- const visible = blocks.some(block =>
- block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
- if (visible) {
- interruptedNodes.push({
- kind: 'assistant', seq: event.seq - 0.9, time: event.time,
- turn: partial.turn, step: partial.step, blocks, interrupted: true,
- })
- }
- partial = null
- }
- let callOffset = 0
- for (const [callId, call] of openCalls) {
- if (call.turn !== event.data.turn) continue
- openCalls.delete(callId)
- // Interrupted terminal nodes are reconstructed independently so a
- // Trajectory replay cannot observe Session's frozen-node lifecycle.
- /* jscpd:ignore-start */
- interruptedNodes.push({
- kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
- time: event.time,
- callId,
- call: { name: call.name, argsRaw: call.argsRaw },
- callTime: call.time,
- content: [],
- isError: true,
- error: { name: 'Interrupted', code: 'interrupted' },
- callView: call.callView,
- resultView: null,
- subCalls: [],
- })
- /* jscpd:ignore-end */
- }
- break
- }
- default:
- break
- }
- }
-
- return {
- interruptedNodes,
- partial: partial?.toPartial() ?? null,
- runningCalls: [...openCalls.values()],
- toolCallTree,
- }
-}
-
-/**
- * Project one immutable history ledger without reading or mutating Chat state.
- * @param entries - Contiguous history entries in sequence order.
- * @returns Event order, context lineage, and transient tail state.
- */
-export function projectConversationHistory(
- entries: readonly HistoryEntry[],
-): ConversationHistoryProjection {
- const events = entries.map(entry => entry.event)
- const steeringHistory = new SteeringHistory()
- const steeringSeqs = new Set()
- for (const event of events) {
- if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
- }
- const baseSeq = events[0]?.seq ?? 0
- const eventsBySeq = new Map(events.map(event => [event.seq, event]))
- const callIndex = new Map()
- const resultViews = new Map()
- const assistantSteps = new Map()
- const assistantTimings = new Map()
- const assistantRequestConfigs = new Map()
- const promptsByContext = new Map()
- let activeRequestConfig: AssistantRequestConfig | undefined
- let activePrompt: ConversationPromptSnapshot | undefined
- let contextGeneration = 0
-
- for (const [index, event] of events.entries()) {
- const view = entries[index]?.view
- if (event.type === 'tool/call') {
- callIndex.set(String(event.data.callId), {
- name: event.data.name,
- argsRaw: event.data.arguments,
- time: event.time,
- callView: view?.for === 'call' ? view.view : null,
- })
- } else if (event.type === 'tool/result' && view?.for === 'result') {
- resultViews.set(event.seq, view.view)
- }
- if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
- contextGeneration++
- if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
- }
- indexAssistantStepTiming(assistantSteps, event)
- if (event.type === 'request/header') {
- activeRequestConfig = event.data.header.config
- activePrompt = {
- config: event.data.header.config,
- system: event.data.header.system ?? '',
- tools: event.data.header.tools ?? [],
- }
- promptsByContext.set(contextGeneration, activePrompt)
- } else if (event.type === 'assistant/message') {
- assistantTimings.set(
- event.seq,
- settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time),
- )
- if (activeRequestConfig !== undefined) {
- assistantRequestConfigs.set(event.seq, activeRequestConfig)
- }
- }
- }
-
- const nodeCache = new Map()
- const materialize = (seq: number): ConversationNode | undefined => {
- const cached = nodeCache.get(seq)
- if (cached !== undefined) return cached
- const event = eventsBySeq.get(seq)
- if (event === undefined || !isSurfaceEligibleType(event.type)) return
- const node = materializeNode(
- event,
- callIndex,
- resultViews.get(seq) ?? null,
- assistantTimings.get(seq),
- assistantRequestConfigs.get(seq),
- steeringSeqs.has(seq),
- )
- nodeCache.set(seq, node)
- return node
- }
- const eventNodes = events.flatMap((event) => {
- const node = materialize(event.seq)
- return node === undefined ? [] : [node]
- })
-
- let contexts: readonly ConversationContext[]
- if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
- contexts = [{
- id: 0,
- ...(activePrompt === undefined ? {} : { prompt: activePrompt }),
- nodes: eventNodes,
- }]
- } else {
- try {
- contexts = foldContexts(events).map((context): ConversationContext => {
- const nodes = context.nodes.flatMap((seq) => {
- const node = materialize(seq)
- return node === undefined ? [] : [node]
- })
- const prompt = promptsByContext.get(context.generation)
- if (context.originSeq === undefined) {
- return {
- id: context.generation,
- ...(prompt === undefined ? {} : { prompt }),
- nodes,
- }
- }
- const originEvent = eventsBySeq.get(context.originSeq)
- return {
- id: context.generation,
- parentId: context.generation - 1,
- origin: contextOriginKind(originEvent),
- originSeq: context.originSeq,
- ...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
- ...(prompt === undefined ? {} : { prompt }),
- nodes,
- }
- })
- } catch (error) {
- console.error('[web-runtime] history surface fold failed, using event order:', error)
- contexts = [{
- id: 0,
- ...(activePrompt === undefined ? {} : { prompt: activePrompt }),
- nodes: eventNodes,
- }]
- }
- }
-
- const transient = projectTransient(entries)
- const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
- const projectedContexts = contexts.map((context): ConversationContext => {
- const nodes = transient.toolCallTree.projectNodes(context.nodes)
- return nodes === context.nodes ? context : { ...context, nodes }
- })
- return {
- eventNodes: projectedEventNodes,
- contexts: projectedContexts,
- interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
- partial: transient.partial,
- runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
- }
-}
diff --git a/packages/client/runtime/src/client/session-history/service.ts b/packages/client/runtime/src/client/session-history/service.ts
deleted file mode 100644
index 4705b6566d..0000000000
--- a/packages/client/runtime/src/client/session-history/service.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { Context } from '@deepseek-ai/cordis'
-import type {
- HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
-} from '@deepseek-ai/dsh-client-connection/client'
-import type {
- ISessionHistory, SessionHistoryFace,
-} from '../contract/session-history.ts'
-import { SessionHistorySource } from './source.ts'
-
-/** Root registry and frame router for independent inspection histories. */
-export class SessionHistoryService implements ISessionHistory {
- private readonly sources = new Map()
-
- /**
- * @param ctx - Client root context.
- * @param api - Shared wire client.
- */
- constructor(ctx: Context, private readonly api: IApiClient) {
- ctx.reflect.provide('sessionHistory', this, undefined)
- }
-
- /**
- * Resolve one identity-stable history source.
- * @param sessionId - Host session identity.
- * @returns Source independent from SessionManager.
- */
- source(sessionId: SessionId): SessionHistoryFace {
- let source = this.sources.get(sessionId)
- if (source === undefined) {
- source = new SessionHistorySource(sessionId, this.api)
- this.sources.set(sessionId, source)
- }
- return source
- }
-
- /**
- * Route history-relevant mux frames only to an existing source.
- * @param envelope - Validated mux envelope.
- */
- handleMuxEnvelope(envelope: RpcRequest): void {
- const frame = envelope.payload
- if (frame.type === 'stream/error') return
- this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
- }
-
- /**
- * Drop a removed session's independent history source.
- * @param envelope - Validated host envelope.
- */
- handleHostEnvelope(envelope: RpcRequest): void {
- const frame = envelope.payload
- if (frame.type !== 'host/session-removed') return
- this.sources.get(frame.sessionId)?.dispose()
- this.sources.delete(frame.sessionId)
- }
-
- /** Invalidate requests from the dead connection generation. */
- handleDisconnected(): void {
- for (const source of this.sources.values()) source.handleDisconnected()
- }
-
- /** Rebuild every previously activated source from the new generation. */
- handleConnected(): void {
- for (const source of this.sources.values()) source.resync()
- }
-}
diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts
deleted file mode 100644
index 44e760b2b2..0000000000
--- a/packages/client/runtime/src/client/session-history/source.ts
+++ /dev/null
@@ -1,432 +0,0 @@
-import type {
- HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
-} from '@deepseek-ai/dsh-client-connection/client'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
-import type {
- SessionHistoryFace, SessionHistorySnapshot,
-} from '../contract/session-history.ts'
-import {
- compactHistoryInspectionEntries, createHistoryInspection,
-} from '../sessions/history.ts'
-import { Notifier } from '../sessions/notifier.ts'
-import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
-
-const HISTORY_PAGE_MESSAGES = 50
-
-function isAborted(signal: AbortSignal | undefined): boolean {
- return signal?.aborted === true
-}
-
-/** Independent raw-history owner used only by inspection consumers. */
-export class SessionHistorySource implements SessionHistoryFace {
- private entries: HistoryEntry[] = []
- private inspectionEntries: readonly HistoryEntry[] = []
- private baseSeq = 0
- private hasMore = false
- private state: SessionHistorySnapshot['state'] = 'cold'
- private error: RpcError | null = null
- private generation = 0
- private persistentConsumer = false
- private readonly consumerSignals = new Set()
- private openPromise: Promise | null = null
- private olderPromise: Promise | null = null
- private stitching = false
- private liveBuffer: HistoryEntry[] = []
- private subscribedLastSeq: number | null = null
- private inspectionCache: {
- entries: readonly HistoryEntry[]
- value: SessionHistorySnapshot['inspection']
- } | null = null
- private streamPublishToken: object | null = null
- private streamPartial: PartialAccumulator | null = null
- private snapshotCache: SessionHistorySnapshot
- private readonly notifier = new Notifier(() => {
- this.snapshotCache = this.buildSnapshot()
- })
-
- /**
- * @param sessionId - Host session identity.
- * @param api - Shared wire client.
- */
- constructor(
- readonly sessionId: SessionId,
- private readonly api: IApiClient,
- ) {
- this.snapshotCache = this.buildSnapshot()
- }
-
- /**
- * Subscribe to ledger changes.
- * @param listener - Change callback.
- * @returns Unsubscribe function.
- */
- subscribe(listener: () => void): () => void {
- return this.notifier.subscribe(listener)
- }
-
- /**
- * Read the cached ledger snapshot.
- * @returns Stable snapshot until the source changes.
- */
- getSnapshot(): SessionHistorySnapshot {
- this.notifier.ensureFresh()
- return this.snapshotCache
- }
-
- /**
- * Load the current tail without reading older pages.
- * @param signal - Consumer lifetime.
- * @returns When the tail is ready or loading fails.
- */
- async loadTail(signal?: AbortSignal): Promise {
- if (isAborted(signal)) return
- this.trackConsumer(signal)
- await this.open()
- }
-
- /**
- * Prepend one older page when the current window has a predecessor.
- * @param signal - Consumer lifetime.
- * @returns Whether the loaded window advanced.
- */
- async loadOlder(signal?: AbortSignal): Promise {
- if (isAborted(signal)) return false
- this.trackConsumer(signal)
- await this.open()
- if (isAborted(signal)) return false
- const previousBaseSeq = this.baseSeq
- await this.loadOlderPage()
- return this.baseSeq !== previousBaseSeq
- }
-
- /**
- * Route a relevant mux frame without involving the Chat session.
- * @param frame - Session-addressed frame.
- */
- handleMuxFrame(frame: MuxFrame): void {
- if (frame.type === 'session/subscribed') {
- this.subscribedLastSeq = frame.lastSeq
- return
- }
- if (frame.type !== 'session/event') return
- this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
- }
-
- /** Invalidate dead-generation requests while retaining the last readable snapshot. */
- handleDisconnected(): void {
- this.generation++
- this.openPromise = null
- this.olderPromise = null
- this.stitching = false
- this.liveBuffer = []
- this.subscribedLastSeq = null
- if (this.state !== 'cold') {
- this.state = 'cold'
- this.error = null
- this.publishDirtyNow()
- }
- }
-
- /** Rebuild an activated ledger from the new connection generation. */
- resync(): void {
- if (!this.hasConsumer()) return
- this.generation++
- this.openPromise = null
- this.olderPromise = null
- this.stitching = false
- this.liveBuffer = []
- this.subscribedLastSeq = null
- this.entries = []
- this.inspectionEntries = []
- this.baseSeq = 0
- this.hasMore = false
- this.state = 'cold'
- this.error = null
- this.publishDirtyNow()
- void this.open()
- }
-
- /** Stop future refresh work after the host removes the session. */
- dispose(): void {
- this.persistentConsumer = false
- this.consumerSignals.clear()
- this.generation++
- this.openPromise = null
- this.olderPromise = null
- this.liveBuffer = []
- this.streamPublishToken = null
- this.streamPartial = null
- }
-
- private open(): Promise {
- if (this.state === 'ready') return Promise.resolve()
- if (this.openPromise !== null) return this.openPromise
- const generation = this.generation
- const operation = this.doOpen(generation)
- const settled = operation.finally(() => {
- if (this.openPromise === settled) this.openPromise = null
- })
- this.openPromise = settled
- return settled
- }
-
- private trackConsumer(signal: AbortSignal | undefined): void {
- if (signal === undefined) {
- this.persistentConsumer = true
- return
- }
- if (this.consumerSignals.has(signal)) return
- this.consumerSignals.add(signal)
- signal.addEventListener('abort', () => {
- this.consumerSignals.delete(signal)
- }, { once: true })
- }
-
- private hasConsumer(): boolean {
- return this.persistentConsumer || this.consumerSignals.size > 0
- }
-
- private async doOpen(generation: number): Promise {
- this.state = 'loading'
- this.error = null
- this.publishDirtyNow()
- try {
- let { result } = await this.api.sessions.history({
- sessionId: this.sessionId,
- maxMessages: HISTORY_PAGE_MESSAGES,
- })
- if (generation !== this.generation) return
- if (!result.ok) {
- this.state = 'error'
- this.error = result.error
- return
- }
- this.installTail(result.value.events, result.value.hasMore, true)
- const tailSeq = this.tailSeq()
- if (
- this.subscribedLastSeq !== null
- && tailSeq !== null
- && this.subscribedLastSeq > tailSeq
- ) {
- result = (await this.api.sessions.history({
- sessionId: this.sessionId,
- maxMessages: HISTORY_PAGE_MESSAGES,
- })).result
- if (generation !== this.generation) return
- if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
- }
- this.state = 'ready'
- } catch (error) {
- if (generation !== this.generation) return
- this.state = 'error'
- const folded = transportError(error)
- /* v8 ignore next -- transportError always returns the error branch. */
- this.error = folded.ok ? null : folded.error
- } finally {
- if (generation === this.generation) this.publishDirtyNow()
- }
- }
-
- private loadOlderPage(): Promise