fix(ui-trajectory): tighten conversation assembly contracts

This commit is contained in:
imccyu
2026-08-10 21:35:57 +08:00
parent b3d3e423f2
commit 62f5d05039
27 changed files with 525 additions and 534 deletions

View File

@@ -4,13 +4,9 @@ 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,
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
trajectoryNodeIdentity,
} from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
@@ -190,10 +186,7 @@ export function TrajectoryView({
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -215,41 +208,8 @@ export function TrajectoryView({
const runningCalls = inspection.runningCalls
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
const interruptedNodes = inspection.interruptedNodes
const contexts = useMemo<readonly ConversationContext[]>(
() => historyContexts.length === 0
? [{ id: 0, nodes }]
: historyContexts,
[historyContexts, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
[contexts],
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const selected = new Map(currentBranch.nodes.map(node => [trajectoryNodeIdentity(node), node]))
for (const node of interruptedNodes) {
selected.set(trajectoryNodeIdentity(node), node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch.nodes, interruptedNodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
@@ -345,24 +305,24 @@ export function TrajectoryView({
return numbered
}, [
contexts, nodes, requests,
nodes, requests,
])
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes: selectedNodes,
nodes,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests: selectedRequests,
requests,
callSchemas,
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas,
nodes, partialTurn, partialStep,
runningCalls, requests, callSchemas,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
@@ -402,9 +362,7 @@ export function TrajectoryView({
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
)
const timelineRange = timelineSelection?.branchKey === currentBranch.key
? timelineSelection.range
: null
const timelineRange = timelineSelection
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
@@ -420,11 +378,8 @@ export function TrajectoryView({
}
}, [timelineFocusIndexes])
const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
setTimelineSelection(range === null ? null : {
branchKey: currentBranch.key,
range,
})
}, [currentBranch.key])
setTimelineSelection(range)
}, [])
const handleTimelineRecordSelect = useCallback((index: number) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
@@ -547,7 +502,6 @@ export function TrajectoryView({
/>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={timelineTurns}
streamingCells={streamingCells}

View File

@@ -1,135 +0,0 @@
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
/** Identity stable when older context generations are prepended. */
key: string
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
/** Seq that opened this branch; earlier requests require retained cited surface events. */
startSeq: number
/** Exact pre-rewind surface records inherited by this branch. */
retainedSurfaceSeqs: ReadonlySet<number>
}
interface MutableBranch {
id: number
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<string, ConversationNode>
startSeq: number
retainedSurfaceSeqs: Set<number>
}
/**
* Resolve the identity used while coalescing one trajectory branch.
* Synthetic tool interruptions share their closing boundary seq, so their
* call ids distinguish parallel roots without inventing false event order.
* @param node - projected conversation node.
* @returns branch-local semantic identity.
*/
export function trajectoryNodeIdentity(node: ConversationNode): string {
return node.kind === 'tool-result'
? `tool-result\u0000${String(node.seq)}\u0000${node.callId}`
: `seq\u0000${String(node.seq)}`
}
function isCompactionCheckpoint(node: ConversationNode): boolean {
if (node.kind !== 'context') return false
const source = node.source
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}
/**
* Join context generations across compaction/rewrite operations and split only at rewind.
* @param contexts - Append-only context generations from the runtime fold.
* @returns Rewind-delimited branches in creation order.
*/
export function deriveTrajectoryContextBranches(
contexts: readonly ConversationContext[],
): readonly TrajectoryContextBranch[] {
const mutable: MutableBranch[] = []
for (const context of contexts) {
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
if (startsBranch) {
const previous = mutable.at(-1)
const retainedSurfaceSeqs = new Set(
context.nodes
.filter(node =>
context.originSeq !== undefined && node.seq < context.originSeq,
)
.map(node => node.seq),
)
const inheritedNodes = previous === undefined
? []
: [...previous.nodes.values()].filter(node =>
retainedSurfaceSeqs.has(node.seq),
)
mutable.push({
id: context.id,
key: context.origin === 'rewind' && context.originSeq !== undefined
? `rewind:${context.originSeq}`
: 'root',
contexts: [context],
latest: context,
nodes: new Map(
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
.map(node => [trajectoryNodeIdentity(node), node]),
),
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
retainedSurfaceSeqs,
})
continue
}
const branch = mutable.at(-1)
if (branch === undefined) continue
branch.contexts.push(context)
branch.latest = context
for (const node of context.nodes) {
if (!isCompactionCheckpoint(node)) branch.nodes.set(trajectoryNodeIdentity(node), node)
}
}
return mutable.map(branch => ({
id: branch.id,
key: branch.key,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
startSeq: branch.startSeq,
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
}))
}
/**
* Test whether a provider request belongs to one rewind branch.
* @param branch - Branch carrying the exact inherited surface event seqs.
* @param request - Provider request to classify.
* @returns Whether the request began on this branch or produced a retained surface record.
*/
export function trajectoryBranchContainsRequest(
branch: TrajectoryContextBranch,
request: RequestView,
): boolean {
if (request.startSeq >= branch.startSeq) return true
return (
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.purpose === 'compaction'
&&
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)
}

View File

@@ -45,9 +45,9 @@ export function apply(ctx: Context): void {
return {
hooks: { duration },
loadOlder: async () => {
const hadMore = session.getSnapshot().hasMore
const before = session.getSnapshot().views.get('trajectory')
await session.loadOlder()
return hadMore
return session.getSnapshot().views.get('trajectory') !== before
},
setActualDuration: (value) => { duration.set(value) },
}

View File

@@ -9,6 +9,9 @@ import {
} from '@deepseek-ai/dsh-client-runtime/client'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
interface UsageValue {
readonly inputTokens: number
readonly outputTokens: number
@@ -389,6 +392,7 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
...(context.state.error === undefined ? {} : { error: context.state.error }),
}),
}
/* jscpd:ignore-end */
/**
* Register the Trajectory Assistant lifecycle.

View File

@@ -1,5 +1,5 @@
import type {
AssistantMessageNode, ConversationContext, ConversationLocation, ConversationNode,
AssistantMessageNode, ConversationLocation, ConversationNode,
ConversationPromptSnapshot, ConversationViewNode, PartialAssistant,
RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -59,10 +59,8 @@ export interface TrajectoryConversationViewNode extends ConversationViewNode {
/** 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[]
}

View File

@@ -1,22 +1,8 @@
import type {
ConversationLocation, ConversationNodeContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { 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.
*
* @param context - Context whose loaded matches provide the Location.
* @returns The start Location, first-match Location, or unresolved fallback.
*/
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.
*

View File

@@ -9,6 +9,9 @@ import {
import type {} from '@deepseek-ai/dsh-agent/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
interface InboxIdentity {
readonly id: string
}
@@ -106,6 +109,7 @@ const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
? null
: trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }),
}
/* jscpd:ignore-end */
/**
* Register Trajectory-owned inbox classification and message records.

View File

@@ -10,17 +10,14 @@ import type {
} from './trajectory-contract.ts'
const EMPTY_LIST: readonly never[] = []
const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }]
type AssistantRequest = Extract<RequestView, { purpose: 'assistant' }>
type ToolSchema = ConversationPromptSnapshot['tools'][number]
/** 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,
}
@@ -249,10 +246,8 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
const eventNodes = finalized
return {
eventNodes,
contexts: [{ id: 0, nodes: eventNodes }],
requests,
callSchemas,
interruptedNodes: EMPTY_LIST,
partial,
runningCalls,
}

View File

@@ -6,6 +6,9 @@ import type {
import type {} from '@deepseek-ai/dsh-tools/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
const MAX_DEPTH = 256
interface ToolState {
@@ -109,11 +112,26 @@ function childResult(
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
let parentDepth = 0
const ancestors = new Set<string>()
while (cursor !== undefined) {
if (cursor === child || ancestors.has(cursor)) return false
ancestors.add(cursor)
parentDepth++
cursor = state.parents.get(cursor)
}
return cursor === undefined
const pending = [{ callId: child, depth: 1 }]
const descendants = new Set<string>()
let subtreeDepth = 0
for (const candidate of pending) {
if (descendants.has(candidate.callId)) return false
descendants.add(candidate.callId)
subtreeDepth = Math.max(subtreeDepth, candidate.depth)
for (const nested of state.children.get(candidate.callId) ?? []) {
pending.push({ callId: nested, depth: candidate.depth + 1 })
}
}
return parentDepth + subtreeDepth <= MAX_DEPTH
}
function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
@@ -243,6 +261,7 @@ const trajectoryToolDefinition: ConversationNodeDefinition<ToolState> = {
return trajectoryNode(context, anchorSeq, { kind: 'tool', root })
},
}
/* jscpd:ignore-end */
/**
* Register the Trajectory Tool lifecycle.