tool-call-tree.client.spec.ts 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  2. import { describe, expect, it } from 'vitest'
  3. import type { RunningToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts'
  4. import {
  5. MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
  6. } from '../src/client/model/tool-call-tree.ts'
  7. const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
  8. ({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
  9. const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
  10. at(seq, 'tool/ptc-dispatch-start', {
  11. parentCallId, subCallId, name: 'run_code', arguments: {},
  12. })
  13. const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
  14. at(seq, 'tool/ptc-dispatch', {
  15. parentCallId, subCallId, name: 'run_code', arguments: {},
  16. isError: false, content: [],
  17. })
  18. const root = (callId: string): RunningToolCall => ({
  19. callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
  20. time: 1_700_000_000_000, subCalls: [],
  21. })
  22. describe('ToolCallTree', () => {
  23. it('rejects a self-parenting dispatch edge', () => {
  24. const tree = new ToolCallTree()
  25. const roots = [root('root')]
  26. expect(tree.apply(start(0, 'root', 'root'))).toBe(true)
  27. expect(tree.projectRunningCalls(roots)).toBe(roots)
  28. })
  29. it('rejects a settling edge that would close a multi-call cycle', () => {
  30. const tree = new ToolCallTree()
  31. tree.apply(start(0, 'a', 'b'))
  32. tree.apply(start(1, 'b', 'c'))
  33. expect(tree.apply(settle(2, 'c', 'a'))).toBe(true)
  34. expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
  35. callId: 'a',
  36. subCalls: [{
  37. callId: 'b', parentCallId: 'a',
  38. subCalls: [{ callId: 'c', parentCallId: 'b', subCalls: [] }],
  39. }],
  40. }])
  41. })
  42. it('accepts an acyclic graph with a shared descendant', () => {
  43. const tree = new ToolCallTree()
  44. tree.apply(start(0, 'a', 'b'))
  45. tree.apply(start(1, 'a', 'c'))
  46. tree.apply(start(2, 'b', 'd'))
  47. tree.apply(start(3, 'c', 'd'))
  48. expect(tree.apply(start(4, 'root', 'a'))).toBe(true)
  49. expect(tree.projectRunningCalls([root('root')])).toMatchObject([{
  50. callId: 'root',
  51. subCalls: [{
  52. callId: 'a',
  53. subCalls: [{ callId: 'b' }, { callId: 'c' }],
  54. }],
  55. }])
  56. })
  57. it('rejects an edge beyond the recursive depth safety limit', () => {
  58. const tree = new ToolCallTree()
  59. for (let depth = 1; depth < MAX_TOOL_CALL_TREE_DEPTH; depth++) {
  60. tree.apply(start(depth, `call-${depth - 1}`, `call-${depth}`))
  61. }
  62. expect(tree.apply(start(
  63. MAX_TOOL_CALL_TREE_DEPTH,
  64. `call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`,
  65. `call-${MAX_TOOL_CALL_TREE_DEPTH}`,
  66. ))).toBe(true)
  67. let current: ToolCallBlock = tree.projectRunningCalls([root('call-0')])[0]!
  68. let depth = 1
  69. while (current.subCalls.length > 0) {
  70. current = current.subCalls[0]!
  71. depth++
  72. }
  73. expect(depth).toBe(MAX_TOOL_CALL_TREE_DEPTH)
  74. expect(current.callId).toBe(`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`)
  75. })
  76. })