fix(client): harden nested Tool call projection
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
65
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal file
65
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal 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' }],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
})
|
||||
@@ -133,18 +133,18 @@ describe('render branch tails', () => {
|
||||
snap.runningCalls = [{
|
||||
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
|
||||
time: 7_000, callView: null, subCalls: [{
|
||||
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
|
||||
callTime: 8_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
subCalls: [{
|
||||
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
callTime: 8_500,
|
||||
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
|
||||
subCalls: [],
|
||||
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
|
||||
callTime: 8_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
subCalls: [{
|
||||
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
callTime: 8_500,
|
||||
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
|
||||
subCalls: [],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
|
||||
README.md: bab3f92e4f6780b041cee2e682cb8ece3386ef15
|
||||
README.zh.md: 9187510fa354ba072835271d4b1153c8ee1105c0
|
||||
README.md: d6bc0f248cffaf4c65ecf6d97c7a4afb17fc8941
|
||||
README.zh.md: f4e8044fe6e10e1fbdfb70987efe07c884fa9ac8
|
||||
|
||||
@@ -44,5 +44,6 @@ None. The package is client-only presentation.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Host excludes `run_code` from Code Mode program bindings, so production events currently produce one dispatch level; the recursive Runtime/UI contract is ready for future nested producers.
|
||||
- Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot.
|
||||
- Tool copy temporarily reuses the `ui-conversation` locale namespace.
|
||||
|
||||
@@ -44,5 +44,6 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- Host 不把 `run_code` 暴露为 Code Mode 程序 binding,因此生产事件目前只能产生一层分发;递归的运行时/UI 契约已为未来的嵌套生产者做好准备。
|
||||
- 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。
|
||||
- Tool 文案暂时复用 `ui-conversation` locale namespace。
|
||||
|
||||
@@ -56,7 +56,9 @@ const runningCode = (callId: string): RunningToolCall => ({
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): ToolCallBlock => ({
|
||||
const subCall = (
|
||||
seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false,
|
||||
): ToolCallBlock => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000,
|
||||
callId: `${parent}:code:${n}`,
|
||||
call: { name, argsRaw: JSON.stringify(args) },
|
||||
|
||||
@@ -917,7 +917,6 @@ function expandSubCalls(
|
||||
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
|
||||
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||||
callId: sub.callId,
|
||||
subCalls: sub.subCalls,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'subtool',
|
||||
|
||||
Reference in New Issue
Block a user