tool-call-tree.client.spec.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. // @vitest-environment jsdom
  2. /** ToolCallTree-owned root/subcall markers and selection projection. */
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { cleanup, render } from '@testing-library/react'
  5. import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
  6. import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
  7. import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
  8. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  9. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  10. import type { ToolCallOwnerProps, ToolTreeProps } from '../src/client/contract/slots.ts'
  11. import { ToolCallTree } from '../src/client/tool/ToolCallTree.tsx'
  12. import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
  13. afterEach(cleanup)
  14. const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
  15. const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
  16. kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
  17. content: [], isError: false, subCalls: [],
  18. })
  19. function props(
  20. block: ToolResultNode,
  21. selectedCallId?: string,
  22. description?: HostDescription,
  23. owners?: ToolCallOwnerProps[],
  24. ): ToolTreeProps {
  25. const snapshot = {} as SessionSnapshot
  26. const useSession = ((selector: (value: SessionSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
  27. const renderSlot = ((_key: string, owner: ToolCallOwnerProps, options?: { fallback?: React.ReactNode }) => {
  28. owners?.push(owner)
  29. return options?.fallback ?? null
  30. }) as unknown as ToolTreeProps['renderSlot']
  31. return {
  32. useSession,
  33. renderSlot,
  34. node: {
  35. key: `tool:${block.callId}`,
  36. kind: 'tool-call',
  37. id: block.callId,
  38. target: 'chat',
  39. anchorSeq: block.seq,
  40. location: { kind: 'session' },
  41. visibility: 'visible',
  42. data: { root: block },
  43. },
  44. selectedCallId,
  45. openFile: vi.fn(),
  46. inspectCall: vi.fn(),
  47. forkAt: vi.fn(),
  48. fileMentions: vi.fn(),
  49. useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'],
  50. t,
  51. } as unknown as ToolTreeProps
  52. }
  53. describe('ToolCallTree', () => {
  54. it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
  55. const block = root('w1', null)
  56. const view = render(<ToolCallTree {...props(block, 'w1')} />)
  57. const row = view.container.querySelector('[data-chat-call-id="w1"]')
  58. expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
  59. expect(row?.getAttribute('data-selected')).toBe('true')
  60. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
  61. expect(view.getByText('w1')).toBeTruthy()
  62. })
  63. it('recursively renders a selected leaf without selecting its ancestors', () => {
  64. const owners: ToolCallOwnerProps[] = []
  65. const leaf = {
  66. ...root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }),
  67. parentCallId: 'parent:code:1',
  68. }
  69. const child = {
  70. ...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
  71. parentCallId: 'parent',
  72. subCalls: [leaf],
  73. }
  74. const block = {
  75. ...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
  76. subCalls: [child],
  77. }
  78. const view = render(<ToolCallTree {...props(block, leaf.callId, undefined, owners)} />)
  79. const nests = view.container.querySelectorAll('[data-subcalls]')
  80. expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
  81. expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
  82. expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
  83. expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
  84. expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
  85. expect(nests).toHaveLength(2)
  86. expect(owners.map(owner => [owner.callId, owner.block.parentCallId ?? null])).toEqual([
  87. ['parent', null],
  88. ['parent:code:1', 'parent'],
  89. ['parent:code:1:code:1', 'parent:code:1'],
  90. ])
  91. })
  92. it('abbreviates a POSIX home path in the generic tool summary', () => {
  93. const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' })
  94. const view = render(<ToolCallTree {...props(block, 'w1', {
  95. version: '0', cwd: '/tmp', attachedSessions: 0, home: '/h', canOpenPath: false,
  96. })} />)
  97. expect(view.getByText('~/docs/a.ts')).toBeTruthy()
  98. })
  99. })