fix(ui-trajectory): retain parallel tool interruptions

This commit is contained in:
imccyu
2026-08-10 20:54:37 +08:00
parent fc4df896a0
commit b3d3e423f2
3 changed files with 46 additions and 5 deletions

View File

@@ -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])

View File

@@ -23,11 +23,24 @@ interface MutableBranch {
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
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
@@ -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 => ({

View File

@@ -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'])
})
})