refactor(client): localize nested Tool call trees
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user