diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 6d77f907f1..95476ac851 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -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 = 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 } - loadHistoryTail: (signal: AbortSignal) => Promise - loadOlderHistory: (signal: AbortSignal) => Promise + loadOlder: () => Promise 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) { const [collapsedTurns, setCollapsedTurns] = useState>(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(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 (
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))} />, )