fix(ui-trajectory): tighten conversation assembly contracts
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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) },
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user