fix(client): harden nested Tool call projection

This commit is contained in:
imccyu
2026-08-08 17:46:50 +08:00
parent 9ed33dfe4f
commit 091b993198
18 changed files with 160 additions and 52 deletions

View File

@@ -10,9 +10,9 @@ interface ProjectedBlock {
value: ToolCallBlock
}
function sameBlocks(
left: readonly ToolCallBlock[],
right: readonly ToolCallBlock[],
function sameReferences<T>(
left: readonly T[],
right: readonly T[],
): boolean {
return left.length === right.length
&& left.every((block, index) => block === right[index])
@@ -68,6 +68,7 @@ export class ToolCallTree {
subCalls: [],
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
if (this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
this.childrenByParent.set(data.parentCallId, [...siblings, running])
this.revision++
return true
@@ -83,6 +84,7 @@ export class ToolCallTree {
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
if (at === -1 && this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
const started = at === -1 ? undefined : siblings[at]
const settled: ToolResultNode = {
kind: 'tool-result',
@@ -116,14 +118,11 @@ export class ToolCallTree {
if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) {
return this.nodesCache.value
}
let changed = false
const projected = nodes.map((node): ConversationNode => {
if (node.kind !== 'tool-result') return node
const value = this.projectBlock(node) as ToolResultNode
changed ||= value !== node
return value
return this.projectBlock(node) as ToolResultNode
})
const value = changed ? projected : nodes
const value = sameReferences(nodes, projected) ? nodes : projected
this.nodesCache = { source: nodes, revision: this.revision, value }
return value
}
@@ -137,13 +136,8 @@ export class ToolCallTree {
if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) {
return this.runningCache.value
}
let changed = false
const projected = calls.map((call): RunningToolCall => {
const value = this.projectBlock(call) as RunningToolCall
changed ||= value !== call
return value
})
const value = changed ? projected : calls
const projected = calls.map(call => this.projectBlock(call) as RunningToolCall)
const value = sameReferences(calls, projected) ? calls : projected
this.runningCache = { source: calls, revision: this.revision, value }
return value
}
@@ -151,11 +145,11 @@ export class ToolCallTree {
private projectBlock(block: ToolCallBlock): ToolCallBlock {
const children = this.childrenByParent.get(block.callId) ?? block.subCalls
const projectedChildren = children.map(child => this.projectBlock(child))
const childValue = sameBlocks(children, projectedChildren)
const childValue = sameReferences(children, projectedChildren)
? children
: projectedChildren
const cached = this.projectedByCall.get(block.callId)
if (cached?.source === block && sameBlocks(cached.children, childValue)) {
if (cached?.source === block && sameReferences(cached.children, childValue)) {
return cached.value
}
const value: ToolCallBlock = block.subCalls === childValue
@@ -168,4 +162,19 @@ export class ToolCallTree {
})
return value
}
private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
if (parentCallId === subCallId) return true
const pending = [subCallId]
const visited = new Set(pending)
for (const callId of pending) {
for (const child of this.childrenByParent.get(callId) ?? []) {
if (child.callId === parentCallId) return true
if (visited.has(child.callId)) continue
visited.add(child.callId)
pending.push(child.callId)
}
}
return false
}
}

View File

@@ -168,6 +168,37 @@ describe('projectConversationHistory', () => {
})
})
it('projects nested dispatches onto settled and interrupted history calls', () => {
const projection = projectConversationHistory([
ev.turnStart(0, 1),
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
ev.toolResult(6, 1, 'settled', 'done'),
ev.turnEnd(7, 1),
ev.turnStart(8, 2),
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
ev.turnEnd(11, 2, 'aborted'),
].map(event => ({ event })))
const settled = {
callId: 'settled',
subCalls: [{
callId: 'settled:code:1',
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
}],
}
expect(projection.eventNodes).toMatchObject([settled])
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
expect(projection.interruptedNodes).toMatchObject([{
callId: 'interrupted',
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
}])
})
it('drops completed token payloads without changing inspection projections', () => {
const events = [
ev.user(0, 'before'),

View File

@@ -0,0 +1,65 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import type { RunningToolCall } from '../src/client/sessions/conversation.ts'
import { ToolCallTree } from '../src/client/sessions/tool-call-tree.ts'
const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
at(seq, 'tool/code-dispatch-start', {
parentCallId, subCallId, name: 'run_code', arguments: {},
})
const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
at(seq, 'tool/code-dispatch', {
parentCallId, subCallId, name: 'run_code', arguments: {},
isError: false, content: [],
})
const root = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 1_700_000_000_000, callView: null, subCalls: [],
})
describe('ToolCallTree', () => {
it('rejects a self-parenting dispatch edge', () => {
const tree = new ToolCallTree()
const roots = [root('root')]
expect(tree.apply(start(0, 'root', 'root'))).toBe(true)
expect(tree.projectRunningCalls(roots)).toBe(roots)
})
it('rejects a settling edge that would close a multi-call cycle', () => {
const tree = new ToolCallTree()
tree.apply(start(0, 'a', 'b'))
tree.apply(start(1, 'b', 'c'))
expect(tree.apply(settle(2, 'c', 'a'))).toBe(true)
expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
callId: 'a',
subCalls: [{
callId: 'b',
subCalls: [{ callId: 'c', subCalls: [] }],
}],
}])
})
it('accepts an acyclic graph with a shared descendant', () => {
const tree = new ToolCallTree()
tree.apply(start(0, 'a', 'b'))
tree.apply(start(1, 'a', 'c'))
tree.apply(start(2, 'b', 'd'))
tree.apply(start(3, 'c', 'd'))
expect(tree.apply(start(4, 'root', 'a'))).toBe(true)
expect(tree.projectRunningCalls([root('root')])).toMatchObject([{
callId: 'root',
subCalls: [{
callId: 'a',
subCalls: [{ callId: 'b' }, { callId: 'c' }],
}],
}])
})
})