diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 95476ac851..70e92f72fd 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -9,6 +9,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, + trajectoryNodeIdentity, } from './context-branches.ts' import { TrajectoryTable, @@ -229,9 +230,9 @@ export function TrajectoryView({ 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 => [node.seq, node])) + const selected = new Map(currentBranch.nodes.map(node => [trajectoryNodeIdentity(node), node])) for (const node of interruptedNodes) { - selected.set(node.seq, node) + selected.set(trajectoryNodeIdentity(node), node) } return [...selected.values()].sort((left, right) => left.seq - right.seq) }, [currentBranch.nodes, interruptedNodes]) diff --git a/packages/client/ui-trajectory/src/client/context-branches.ts b/packages/client/ui-trajectory/src/client/context-branches.ts index 2501511c50..2f665bb413 100644 --- a/packages/client/ui-trajectory/src/client/context-branches.ts +++ b/packages/client/ui-trajectory/src/client/context-branches.ts @@ -23,11 +23,24 @@ interface MutableBranch { key: string contexts: ConversationContext[] latest: ConversationContext - nodes: Map + nodes: Map startSeq: number retainedSurfaceSeqs: Set } +/** + * 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 @@ -73,7 +86,7 @@ export function deriveTrajectoryContextBranches( latest: context, nodes: new Map( [...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))] - .map(node => [node.seq, node]), + .map(node => [trajectoryNodeIdentity(node), node]), ), startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY, retainedSurfaceSeqs, @@ -85,7 +98,7 @@ export function deriveTrajectoryContextBranches( branch.contexts.push(context) branch.latest = context for (const node of context.nodes) { - if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node) + if (!isCompactionCheckpoint(node)) branch.nodes.set(trajectoryNodeIdentity(node), node) } } return mutable.map(branch => ({ diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index e608b9fd68..9885e9a4f9 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -34,6 +34,23 @@ const current = { source: { kind: 'plugin', plugin: 'rewind' }, } as ConversationNode +function interruptedTool(callId: string): ConversationNode { + return { + kind: 'tool-result', + seq: 19.2, + time: 20, + callId, + call: { name: 'parallel', argsRaw: '{}' }, + callTime: 10, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: null, + resultView: null, + subCalls: [], + } +} + function request( purpose: RequestView['purpose'], startSeq: number, @@ -99,4 +116,14 @@ describe('trajectory context branches', () => { expect(branch(1)?.key).toBe(branch(9)?.key) }) + + it('retains parallel tool interruptions that share one closing boundary', () => { + const branch = deriveTrajectoryContextBranches([{ + id: 0, + nodes: [interruptedTool('call-a'), interruptedTool('call-b')], + }])[0] + + expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined)) + .toEqual(['call-a', 'call-b']) + }) })