feat(ui-trajectory): assemble registered conversation nodes

This commit is contained in:
imccyu
2026-08-10 18:38:22 +08:00
parent 3e79f7106e
commit f479c60b6d
12 changed files with 1425 additions and 104 deletions

View File

@@ -1,11 +1,11 @@
/** 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 { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
SessionHistoryFace, SnapshotStore,
SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
@@ -27,6 +27,7 @@ import {
type TrajectoryTimeRange,
} from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
import css from './views.module.css'
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
@@ -64,14 +65,12 @@ function partialStructureSignature(partial: ConversationSnapshot['partial']): st
: 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 {
hooks: {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadHistoryTail: (signal: AbortSignal) => Promise<void>
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
loadOlder: () => Promise<boolean>
setActualDuration: (actualDuration: boolean) => void
}
@@ -184,7 +183,7 @@ function mergeSearchMatches(
}
export function TrajectoryView({
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
useSession, useDuration, loadOlder, setActualDuration,
inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
@@ -204,23 +203,15 @@ export function TrajectoryView({
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
readonly index: number
} | 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 historyBaseSeq = nodes[0]?.seq ?? 0
const partial = inspection.partial
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 callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
@@ -518,11 +509,8 @@ export function TrajectoryView({
}
const loadEarlierHistory = useCallback(() => {
const signal = historyControllerRef.current?.signal
return signal?.aborted === false
? loadOlderHistory(signal)
: Promise.resolve(false)
}, [loadOlderHistory])
return loadOlder()
}, [loadOlder])
return (
<div className={css.root} data-conversation-composer-overlay="">

View File

@@ -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) },
}
},

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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'])

View File

@@ -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<SlotsService, SnapshotStore<ConversationSnapshot>>()
const tConversation: ConversationSessionHeaderProps['t'] =
key => (conversationZh as Record<string, string>)[key] ?? key
@@ -67,37 +72,55 @@ const NODES = [
function historySnapshot(
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 {
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<typeof TrajectoryView>,
'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<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. */
@@ -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(() => <div data-testid="chat-body" />)
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<ConversationSnapshot>
const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes))
const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession<ConversationSnapshot>
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', () => {
<TrajectoryView
{...standaloneProps([])}
{...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
{...standaloneProps([])}
{...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/ })
@@ -1225,9 +1234,8 @@ describe('TrajectoryView branches', () => {
<TrajectoryView
{...standaloneProps([])}
{...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/ }))
@@ -1274,9 +1282,8 @@ describe('TrajectoryView branches', () => {
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
useSession={bindSnapshotSelector(store)}
loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)