fix(ui-trajectory): retain parallel tool interruptions
This commit is contained in:
@@ -9,6 +9,7 @@ import type {
|
|||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import {
|
import {
|
||||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||||
|
trajectoryNodeIdentity,
|
||||||
} from './context-branches.ts'
|
} from './context-branches.ts'
|
||||||
import {
|
import {
|
||||||
TrajectoryTable,
|
TrajectoryTable,
|
||||||
@@ -229,9 +230,9 @@ export function TrajectoryView({
|
|||||||
const currentBranch = branches.at(-1)
|
const currentBranch = branches.at(-1)
|
||||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||||
const selectedNodes = useMemo(() => {
|
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) {
|
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)
|
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||||
}, [currentBranch.nodes, interruptedNodes])
|
}, [currentBranch.nodes, interruptedNodes])
|
||||||
|
|||||||
@@ -23,11 +23,24 @@ interface MutableBranch {
|
|||||||
key: string
|
key: string
|
||||||
contexts: ConversationContext[]
|
contexts: ConversationContext[]
|
||||||
latest: ConversationContext
|
latest: ConversationContext
|
||||||
nodes: Map<number, ConversationNode>
|
nodes: Map<string, ConversationNode>
|
||||||
startSeq: number
|
startSeq: number
|
||||||
retainedSurfaceSeqs: Set<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 {
|
function isCompactionCheckpoint(node: ConversationNode): boolean {
|
||||||
if (node.kind !== 'context') return false
|
if (node.kind !== 'context') return false
|
||||||
const source = node.source
|
const source = node.source
|
||||||
@@ -73,7 +86,7 @@ export function deriveTrajectoryContextBranches(
|
|||||||
latest: context,
|
latest: context,
|
||||||
nodes: new Map(
|
nodes: new Map(
|
||||||
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
||||||
.map(node => [node.seq, node]),
|
.map(node => [trajectoryNodeIdentity(node), node]),
|
||||||
),
|
),
|
||||||
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
||||||
retainedSurfaceSeqs,
|
retainedSurfaceSeqs,
|
||||||
@@ -85,7 +98,7 @@ export function deriveTrajectoryContextBranches(
|
|||||||
branch.contexts.push(context)
|
branch.contexts.push(context)
|
||||||
branch.latest = context
|
branch.latest = context
|
||||||
for (const node of context.nodes) {
|
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 => ({
|
return mutable.map(branch => ({
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ const current = {
|
|||||||
source: { kind: 'plugin', plugin: 'rewind' },
|
source: { kind: 'plugin', plugin: 'rewind' },
|
||||||
} as ConversationNode
|
} 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(
|
function request(
|
||||||
purpose: RequestView['purpose'],
|
purpose: RequestView['purpose'],
|
||||||
startSeq: number,
|
startSeq: number,
|
||||||
@@ -99,4 +116,14 @@ describe('trajectory context branches', () => {
|
|||||||
|
|
||||||
expect(branch(1)?.key).toBe(branch(9)?.key)
|
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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user