refactor(client): localize nested Tool call trees

This commit is contained in:
imccyu
2026-08-08 17:08:22 +08:00
parent 5e0b7571e6
commit 9ed33dfe4f
48 changed files with 586 additions and 413 deletions

View File

@@ -211,7 +211,6 @@ export function TrajectoryView({
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
@@ -366,12 +365,11 @@ export function TrajectoryView({
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches,
runningCalls, selectedRequests, callSchemas,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null

View File

@@ -5,11 +5,11 @@
import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
ConversationSnapshot,
RequestInspectionSnapshot,
RequestPromptChange,
RequestView,
ToolCallBlock,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -39,8 +39,6 @@ export interface TrajectoryLayoutInput {
runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
interface UsageLike {
@@ -57,6 +55,7 @@ interface LaidCell {
absTime: number | null
toolName?: string
callId?: string
subCalls?: readonly ToolCallBlock[]
}
interface LaidGroup {
@@ -131,9 +130,11 @@ function inputCellDetail(node: InputNode): Pick<
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const {
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
nodes, partial, runningCalls, requests = [], callSchemas,
} = input
const resultByCall = indexResults(nodes)
const callById = new Map<string, ToolCallBlock>(resultByCall)
for (const call of runningCalls) callById.set(call.callId, call)
const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
@@ -344,8 +345,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
codeDispatches,
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
)
if (node.step > 0) pushStep(node.turn, node.step, laidList)
else for (const laid of laidList) pushMessage(node.turn, laid)
@@ -381,6 +381,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
callId: node.callId,
subCalls: node.subCalls,
cell: {
index: ++index,
kind: 'tool',
@@ -398,7 +399,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(node.callTime),
},
}]
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
for (const laid of expandSubCalls(node.subCalls, index)) {
laidList.push(laid)
index = laid.cell.index
}
@@ -413,14 +414,15 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
turn: partial.turn, step: partial.step, blocks: partial.blocks,
}
const laidList = expandAssistant(
const laidList = withSubCalls(expandAssistant(
fake,
index + 1,
prevAbsTime,
resultByCall,
callStartById,
callById,
{ streaming: true },
)
))
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
else for (const laid of laidList) pushMessage(partial.turn, laid)
const last = laidList[laidList.length - 1]
@@ -434,6 +436,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: null,
toolName: call.name,
callId: call.callId,
subCalls: call.subCalls,
cell: {
index: ++index,
kind: 'tool',
@@ -444,7 +447,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(call.time),
},
}]
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
for (const laid of expandSubCalls(call.subCalls, index)) {
laidList.push(laid)
index = laid.cell.index
}
@@ -491,7 +494,6 @@ export function appendTrajectoryPartialLayout(
nodes: [],
partial,
runningCalls: [],
codeDispatches: new Map(),
}).at(0)
if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = {
@@ -622,6 +624,7 @@ function expandAssistant(
prevAbsTime: number | null,
results: Map<string, ToolResultNode>,
callStarts: ReadonlyMap<string, number>,
calls: ReadonlyMap<string, ToolCallBlock>,
opts?: { streaming?: boolean },
): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return []
@@ -677,10 +680,12 @@ function expandAssistant(
? null
: durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId))
const call = calls.get(block.callId)
out.push({
absTime: callAbs,
toolName: block.name,
callId: block.callId,
...(call === undefined ? {} : { subCalls: call.subCalls }),
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
@@ -883,15 +888,14 @@ function collectCallIds(
/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
if (codeDispatches.size === 0) return laidList
/** Interleave each tool cell's nested child calls right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[]): LaidCell[] {
if (!laidList.some(laid => laid.subCalls !== undefined && laid.subCalls.length > 0)) return laidList
const out: LaidCell[] = []
let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
for (const laid of laidList) {
out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
if (laid.callId === undefined) continue
for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
for (const sub of expandSubCalls(laid.subCalls, index)) {
out.push(sub)
index = sub.cell.index
}
@@ -901,7 +905,7 @@ function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot
/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
function expandSubCalls(
subs: readonly CodeSubCall[] | undefined,
subs: readonly ToolCallBlock[] | undefined,
startIndex: number,
): LaidCell[] {
if (subs === undefined || subs.length === 0) return []
@@ -909,10 +913,11 @@ function expandSubCalls(
let index = startIndex
for (const sub of subs) {
const settled = 'kind' in sub
out.push({
const laid: LaidCell = {
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',
@@ -938,7 +943,12 @@ function expandSubCalls(
? finiteTime(sub.callTime)
: finiteTime(sub.time),
},
})
}
out.push(laid)
for (const child of expandSubCalls(sub.subCalls, index)) {
out.push(child)
index = child.cell.index
}
}
return out
}

View File

@@ -74,7 +74,7 @@ describe('deriveTrajectoryLayout', () => {
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
@@ -90,12 +90,11 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null,
turn: 1, step: 2, time: 9_000, callView: null, subCalls: [],
}],
})
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
@@ -119,7 +118,6 @@ describe('deriveTrajectoryLayout', () => {
startedAt: 3_000, completedAt: null, status: 'running',
} as unknown as RequestView
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: { ...partial, blocks: [] },
requests: [request],
@@ -152,12 +150,11 @@ describe('deriveTrajectoryLayout', () => {
}],
}
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, callView: null,
turn: 1, step: 1, time: 9_000, callView: null, subCalls: [],
}],
})
@@ -180,7 +177,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
@@ -206,7 +203,7 @@ describe('deriveTrajectoryLayout', () => {
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
})
@@ -223,7 +220,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'ok2' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
@@ -254,7 +251,6 @@ describe('deriveTrajectoryLayout', () => {
}
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: null,
runningCalls: [],
@@ -280,7 +276,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '…', input: 11, output: 22, think: 3,
@@ -295,7 +291,7 @@ describe('deriveTrajectoryLayout', () => {
}] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
@@ -333,7 +329,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
@@ -352,7 +348,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
@@ -372,6 +368,7 @@ describe('run_code sub-dispatch cells', () => {
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
subCalls: [],
},
] as unknown as ConversationSnapshot['nodes']
@@ -380,14 +377,18 @@ describe('run_code sub-dispatch cells', () => {
callId: `p1:code:${n}`,
call: { name, argsRaw: '{"x":1}' }, callTime: start,
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
subCalls: [],
})
const withSubCalls = (subCalls: readonly ReturnType<typeof settledSub>[] | readonly object[]) =>
runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as ConversationSnapshot['nodes']
it('nests settled sub-cells after their parent Tool cell with real durations', () => {
const codeDispatches = new Map([['p1', [
const subCalls = [
settledSub(1, 'bash', 6_300, 7_300),
settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
]
const turns = deriveTrajectoryLayout({ nodes: withSubCalls(subCalls), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells[0]?.text).toBe('Tool call only')
@@ -400,11 +401,29 @@ describe('run_code sub-dispatch cells', () => {
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
turn: 0, step: 0, time: 6_400, callView: null,
turn: 0, step: 0, time: 6_400, callView: null, subCalls: [],
}
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
})
it('recursively flattens nested child calls immediately after their parent', () => {
const leaf = {
...settledSub(2, 'read', 7_300, 7_800),
callId: 'p1:code:1:code:1',
}
const child = {
...settledSub(1, 'run_code', 6_300, 8_000),
subCalls: [leaf],
}
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([child]), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(group => group.cells)
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells.slice(2).map(cell => cell.callId)).toEqual([
'p1:code:1',
'p1:code:1:code:1',
])
expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
})
})

View File

@@ -82,7 +82,6 @@ function historySnapshot(
interruptedNodes: [],
partial: null,
runningCalls: [],
codeDispatches: new Map(),
...inspection,
},
}
@@ -115,7 +114,7 @@ function standaloneDuration(): Pick<
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, pending: [], partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
runningCalls: [] as ConversationSnapshot['runningCalls'],
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -186,7 +185,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
running: false, removed: false, promptError: null, nodes,
pending: [],
openState: 'open' as const, hasMore: true, loadingOlder: false,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create()