feat(ui-trajectory): assemble registered conversation nodes
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type {
|
import type {
|
||||||
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
|
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
|
||||||
SessionHistoryFace, SnapshotStore,
|
SnapshotStore,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import {
|
import {
|
||||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type TrajectoryTimeRange,
|
type TrajectoryTimeRange,
|
||||||
} from './timeline.ts'
|
} from './timeline.ts'
|
||||||
import { trajectoryRecordId } from './trajectory-record.ts'
|
import { trajectoryRecordId } from './trajectory-record.ts'
|
||||||
|
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
|
||||||
import css from './views.module.css'
|
import css from './views.module.css'
|
||||||
|
|
||||||
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
|
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
|
||||||
@@ -64,14 +65,12 @@ function partialStructureSignature(partial: ConversationSnapshot['partial']): st
|
|||||||
: block.kind).join('\u0000')
|
: block.kind).join('\u0000')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Session-history paging needed by the event-complete trajectory view. */
|
/** Session-bound controls not already supplied by the conversation view slot. */
|
||||||
export interface TrajectoryViewInjected {
|
export interface TrajectoryViewInjected {
|
||||||
hooks: {
|
hooks: {
|
||||||
history: SessionHistoryFace
|
|
||||||
duration: SnapshotStore<boolean>
|
duration: SnapshotStore<boolean>
|
||||||
}
|
}
|
||||||
loadHistoryTail: (signal: AbortSignal) => Promise<void>
|
loadOlder: () => Promise<boolean>
|
||||||
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
|
|
||||||
setActualDuration: (actualDuration: boolean) => void
|
setActualDuration: (actualDuration: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +183,7 @@ function mergeSearchMatches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TrajectoryView({
|
export function TrajectoryView({
|
||||||
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
|
useSession, useDuration, loadOlder, setActualDuration,
|
||||||
inspect, onInspectDone,
|
inspect, onInspectDone,
|
||||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
||||||
@@ -204,23 +203,15 @@ export function TrajectoryView({
|
|||||||
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
|
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
|
||||||
readonly index: number
|
readonly index: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
const inspection = useSession(snapshot =>
|
||||||
const historyLoading = useHistory(snapshot =>
|
snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
|
||||||
snapshot.state === 'cold' || snapshot.state === 'loading')
|
const historyLoading = useSession(snapshot =>
|
||||||
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
|
snapshot.openState === 'loading' || snapshot.loadingOlder)
|
||||||
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
|
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
|
||||||
const nodes = inspection.eventNodes
|
const nodes = inspection.eventNodes
|
||||||
|
const historyBaseSeq = nodes[0]?.seq ?? 0
|
||||||
const partial = inspection.partial
|
const partial = inspection.partial
|
||||||
const runningCalls = inspection.runningCalls
|
const runningCalls = inspection.runningCalls
|
||||||
const loadHistoryTailRef = useRef(loadHistoryTail)
|
|
||||||
loadHistoryTailRef.current = loadHistoryTail
|
|
||||||
const historyControllerRef = useRef<AbortController | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController()
|
|
||||||
historyControllerRef.current = controller
|
|
||||||
void loadHistoryTailRef.current(controller.signal)
|
|
||||||
return () => { controller.abort() }
|
|
||||||
}, [])
|
|
||||||
const requests = inspection.requests
|
const requests = inspection.requests
|
||||||
const callSchemas = inspection.callSchemas
|
const callSchemas = inspection.callSchemas
|
||||||
const historyContexts = inspection.contexts
|
const historyContexts = inspection.contexts
|
||||||
@@ -518,11 +509,8 @@ export function TrajectoryView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadEarlierHistory = useCallback(() => {
|
const loadEarlierHistory = useCallback(() => {
|
||||||
const signal = historyControllerRef.current?.signal
|
return loadOlder()
|
||||||
return signal?.aborted === false
|
}, [loadOlder])
|
||||||
? loadOlderHistory(signal)
|
|
||||||
: Promise.resolve(false)
|
|
||||||
}, [loadOlderHistory])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.root} data-conversation-composer-overlay="">
|
<div className={css.root} data-conversation-composer-overlay="">
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|||||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||||
|
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||||
|
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||||
|
import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts'
|
||||||
|
import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts'
|
||||||
|
import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts'
|
||||||
|
import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts'
|
||||||
|
|
||||||
/** Required services: the conversation view slot and independent history source. */
|
/** Required services: the conversation slot, registries, and ordinary Session paging. */
|
||||||
export const inject = ['slots', 'sessionHistory']
|
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client plugin body: register the trajectory view tab. The registration
|
* Client plugin body: register the trajectory view tab. The registration
|
||||||
@@ -20,17 +26,29 @@ export const inject = ['slots', 'sessionHistory']
|
|||||||
*/
|
*/
|
||||||
export function apply(ctx: Context): void {
|
export function apply(ctx: Context): void {
|
||||||
const duration = createTrajectoryDurationStore()
|
const duration = createTrajectoryDurationStore()
|
||||||
|
registerTrajectoryMessageDefinitions(ctx)
|
||||||
|
registerTrajectoryRequestHeaderDefinition(ctx)
|
||||||
|
registerTrajectoryAssistantDefinition(ctx)
|
||||||
|
registerTrajectoryToolDefinition(ctx)
|
||||||
|
registerTrajectoryCompactionDefinitions(ctx)
|
||||||
|
registerTrajectoryConversationView(ctx)
|
||||||
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
||||||
name: 'conversation.view',
|
name: 'conversation.view',
|
||||||
id: 'trajectory',
|
id: 'trajectory',
|
||||||
order: 10,
|
order: 10,
|
||||||
label: 'Trajectory',
|
label: 'Trajectory',
|
||||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||||
const history = ctx.sessionHistory.source(sessionId)
|
const session = ctx.sessions.binding(sessionId)?.session
|
||||||
|
if (session === undefined) {
|
||||||
|
throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
hooks: { history, duration },
|
hooks: { duration },
|
||||||
loadHistoryTail: signal => history.loadTail(signal),
|
loadOlder: async () => {
|
||||||
loadOlderHistory: signal => history.loadOlder(signal),
|
const hadMore = session.getSnapshot().hasMore
|
||||||
|
await session.loadOlder()
|
||||||
|
return hadMore
|
||||||
|
},
|
||||||
setActualDuration: (value) => { duration.set(value) },
|
setActualDuration: (value) => { duration.set(value) },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ import { readFileSync } from 'node:fs'
|
|||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { Context } from '@deepseek-ai/cordis'
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
import {
|
||||||
|
ConversationEventRegistry, ConversationViewRegistry, SlotsService,
|
||||||
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
|
||||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
|
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
|
||||||
|
|
||||||
@@ -61,21 +63,25 @@ describe('tsdown client artifact', () => {
|
|||||||
const { handoff, surface } = await loadArtifact()
|
const { handoff, surface } = await loadArtifact()
|
||||||
expect(handoff.id).toBe(PLUGIN_ID)
|
expect(handoff.id).toBe(PLUGIN_ID)
|
||||||
expect(surface.apply).toBeTypeOf('function')
|
expect(surface.apply).toBeTypeOf('function')
|
||||||
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
|
expect(surface.inject).toEqual([
|
||||||
|
'slots', 'conversationEvents', 'conversationViews', 'sessions',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||||
const { surface } = await loadArtifact()
|
const { surface } = await loadArtifact()
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const slots = new SlotsService(ctx)
|
const slots = new SlotsService(ctx)
|
||||||
|
await ctx.plugin(ConversationEventRegistry).await()
|
||||||
|
await ctx.plugin(ConversationViewRegistry).await()
|
||||||
// The conversation entry's role: the ring must be declared before riders land.
|
// The conversation entry's role: the ring must be declared before riders land.
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'root',
|
name: 'root',
|
||||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||||
}, (_p: { renderSlot?: unknown }) => null)
|
}, (_p: { renderSlot?: unknown }) => null)
|
||||||
// The plugin reads sessionHistory for its per-session history source;
|
// Paging is session-owned; this registration-only probe never renders the
|
||||||
// slot availability is tracked by slots.inject.
|
// entry, so the binding stays deliberately empty.
|
||||||
ctx.provide('sessionHistory', {})
|
ctx.provide('sessions', { binding: () => undefined })
|
||||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
|
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|||||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
import {
|
||||||
|
ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore,
|
||||||
|
EMPTY_CHAT_SNAPSHOT,
|
||||||
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type {
|
import type {
|
||||||
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
|
ConversationSnapshot, RequestView,
|
||||||
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
SessionId, SessionListState, SnapshotStore, WorkspaceListState,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import {
|
import {
|
||||||
@@ -35,9 +38,11 @@ import {
|
|||||||
TrajectoryView, type TrajectoryViewInjected,
|
TrajectoryView, type TrajectoryViewInjected,
|
||||||
} from '../src/client/TrajectoryView.tsx'
|
} from '../src/client/TrajectoryView.tsx'
|
||||||
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
|
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
|
||||||
|
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
|
||||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||||
|
|
||||||
const SID = 's1' as SessionId
|
const SID = 's1' as SessionId
|
||||||
|
const sessionSnapshots = new WeakMap<SlotsService, SnapshotStore<ConversationSnapshot>>()
|
||||||
const tConversation: ConversationSessionHeaderProps['t'] =
|
const tConversation: ConversationSessionHeaderProps['t'] =
|
||||||
key => (conversationZh as Record<string, string>)[key] ?? key
|
key => (conversationZh as Record<string, string>)[key] ?? key
|
||||||
|
|
||||||
@@ -67,37 +72,55 @@ const NODES = [
|
|||||||
|
|
||||||
function historySnapshot(
|
function historySnapshot(
|
||||||
nodes: ConversationSnapshot['nodes'],
|
nodes: ConversationSnapshot['nodes'],
|
||||||
inspection: Partial<SessionHistoryInspection> = {},
|
inspection: Partial<TrajectorySnapshot> = {},
|
||||||
): SessionHistorySnapshot {
|
): ConversationSnapshot {
|
||||||
|
const trajectory: TrajectorySnapshot = {
|
||||||
|
eventNodes: nodes,
|
||||||
|
contexts: [{ id: 0, nodes }],
|
||||||
|
requests: [],
|
||||||
|
callSchemas: new Map(),
|
||||||
|
interruptedNodes: [],
|
||||||
|
partial: null,
|
||||||
|
runningCalls: [],
|
||||||
|
...inspection,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
state: 'ready',
|
sessionId: SID,
|
||||||
error: null,
|
views: {
|
||||||
|
get: target => target === 'trajectory' ? trajectory : undefined,
|
||||||
|
} as ConversationSnapshot['views'],
|
||||||
|
chat: EMPTY_CHAT_SNAPSHOT,
|
||||||
|
nodes,
|
||||||
|
turnTimings: new Map(),
|
||||||
|
turnEnds: new Map(),
|
||||||
|
partial: trajectory.partial,
|
||||||
|
runningCalls: trajectory.runningCalls,
|
||||||
|
pending: [],
|
||||||
|
queue: [],
|
||||||
|
running: false,
|
||||||
|
subagent: null,
|
||||||
|
composerPhase: 'active',
|
||||||
|
removed: false,
|
||||||
|
openState: 'open',
|
||||||
|
openError: null,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
baseSeq: nodes[0]?.seq ?? 0,
|
loadingOlder: false,
|
||||||
inspection: {
|
promptError: null,
|
||||||
eventNodes: nodes,
|
blank: nodes.length === 0,
|
||||||
contexts: [{ id: 0, nodes }],
|
lastAgentError: null,
|
||||||
requests: [],
|
|
||||||
callSchemas: new Map(),
|
|
||||||
interruptedNodes: [],
|
|
||||||
partial: null,
|
|
||||||
runningCalls: [],
|
|
||||||
...inspection,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function standaloneHistory(
|
function standaloneHistory(
|
||||||
snapshot: SessionHistorySnapshot,
|
snapshot: ConversationSnapshot,
|
||||||
): Pick<
|
): Pick<
|
||||||
ComponentProps<typeof TrajectoryView>,
|
ComponentProps<typeof TrajectoryView>,
|
||||||
'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
|
'useSession' | 'loadOlder'
|
||||||
> {
|
> {
|
||||||
const store = createSnapshotStore(snapshot)
|
const store = createSnapshotStore(snapshot)
|
||||||
return {
|
return {
|
||||||
useHistory: bindSnapshotSelector(store),
|
useSession: bindSnapshotSelector(store),
|
||||||
loadHistoryTail: () => Promise.resolve(),
|
loadOlder: () => Promise.resolve(false),
|
||||||
loadOlderHistory: () => Promise.resolve(false),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +135,8 @@ function standaloneDuration(): Pick<
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||||
const store = createSnapshotStore({
|
const store = createSnapshotStore(historySnapshot(nodes))
|
||||||
nodes, pending: [], partial: null,
|
return { store, useSession: bindSnapshotSelector(store) as UseSession<ConversationSnapshot> }
|
||||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
|
||||||
})
|
|
||||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||||
@@ -149,16 +169,19 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
|||||||
async function bench(snapshot = historySnapshot(NODES)) {
|
async function bench(snapshot = historySnapshot(NODES)) {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const slots = new SlotsService(ctx)
|
const slots = new SlotsService(ctx)
|
||||||
const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
const loadOlder = vi.fn(() => Promise.resolve())
|
||||||
const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
|
const sessionStore = createSnapshotStore(snapshot)
|
||||||
const historyStore = createSnapshotStore(snapshot)
|
const session = {
|
||||||
const history: SessionHistoryFace = {
|
getSnapshot: () => sessionStore.getSnapshot(),
|
||||||
sessionId: SID,
|
subscribe: (listener: () => void) => sessionStore.subscribe(listener),
|
||||||
getSnapshot: () => historyStore.getSnapshot(),
|
loadOlder,
|
||||||
subscribe: listener => historyStore.subscribe(listener),
|
|
||||||
loadTail: loadHistoryTail,
|
|
||||||
loadOlder: loadOlderHistory,
|
|
||||||
}
|
}
|
||||||
|
await ctx.plugin(ConversationEventRegistry).await()
|
||||||
|
await ctx.plugin(ConversationViewRegistry).await()
|
||||||
|
ctx.provide('sessions', {
|
||||||
|
binding: () => ({ session }),
|
||||||
|
})
|
||||||
|
sessionSnapshots.set(slots, sessionStore)
|
||||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'root',
|
name: 'root',
|
||||||
@@ -167,10 +190,9 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
|||||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||||
slots.register(
|
slots.register(
|
||||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||||
ctx.provide('sessionHistory', { source: () => history })
|
|
||||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
|
return { ctx, slots, fiber, loadOlder }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
||||||
@@ -181,13 +203,8 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
|||||||
|
|
||||||
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
|
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
|
||||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||||
const sessionSnapshot = createSnapshotStore({
|
const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes))
|
||||||
running: false, removed: false, promptError: null, nodes,
|
const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession<ConversationSnapshot>
|
||||||
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 chat = createChatStore().create()
|
const chat = createChatStore().create()
|
||||||
const views = {
|
const views = {
|
||||||
list: () => tabsOf(slots),
|
list: () => tabsOf(slots),
|
||||||
@@ -215,10 +232,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
? (() => {
|
? (() => {
|
||||||
const trajectory = injected as TrajectoryViewInjected
|
const trajectory = injected as TrajectoryViewInjected
|
||||||
return {
|
return {
|
||||||
loadHistoryTail: trajectory.loadHistoryTail,
|
loadOlder: trajectory.loadOlder,
|
||||||
loadOlderHistory: trajectory.loadOlderHistory,
|
|
||||||
setActualDuration: trajectory.setActualDuration,
|
setActualDuration: trajectory.setActualDuration,
|
||||||
useHistory: bindSnapshotSelector(trajectory.hooks.history),
|
|
||||||
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -322,13 +337,9 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
||||||
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
||||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||||
await vi.waitFor(() => {
|
expect(b.loadOlder).not.toHaveBeenCalled()
|
||||||
expect(b.loadHistoryTail).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
const signal = b.loadHistoryTail.mock.calls[0]?.[0]
|
|
||||||
expect(signal?.aborted).toBe(false)
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
||||||
expect(signal?.aborted).toBe(true)
|
expect(b.loadOlder).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
||||||
@@ -1162,9 +1173,8 @@ describe('TrajectoryView branches', () => {
|
|||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
{...standaloneDuration()}
|
{...standaloneDuration()}
|
||||||
useHistory={bindSnapshotSelector(store)}
|
useSession={bindSnapshotSelector(store)}
|
||||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
loadOlder={vi.fn(() => Promise.resolve(false))}
|
||||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1196,9 +1206,8 @@ describe('TrajectoryView branches', () => {
|
|||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
{...standaloneDuration()}
|
{...standaloneDuration()}
|
||||||
useHistory={bindSnapshotSelector(store)}
|
useSession={bindSnapshotSelector(store)}
|
||||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
loadOlder={vi.fn(() => Promise.resolve(false))}
|
||||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
const row = screen.getByRole('row', { name: /stable rewind response/ })
|
const row = screen.getByRole('row', { name: /stable rewind response/ })
|
||||||
@@ -1225,9 +1234,8 @@ describe('TrajectoryView branches', () => {
|
|||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
{...standaloneDuration()}
|
{...standaloneDuration()}
|
||||||
useHistory={bindSnapshotSelector(store)}
|
useSession={bindSnapshotSelector(store)}
|
||||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
loadOlder={vi.fn(() => Promise.resolve(false))}
|
||||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
|
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
|
||||||
@@ -1274,9 +1282,8 @@ describe('TrajectoryView branches', () => {
|
|||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
{...standaloneDuration()}
|
{...standaloneDuration()}
|
||||||
useHistory={bindSnapshotSelector(store)}
|
useSession={bindSnapshotSelector(store)}
|
||||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
loadOlder={vi.fn(() => Promise.resolve(false))}
|
||||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user