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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  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 { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
  6. import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
  7. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  9. import type { ToolCallOwnerProps, ToolTreeProps } from '../src/client/contract/slots.ts'
  10. import { ToolCallTree } from '../src/client/tool/ToolCallTree.tsx'
  11. import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
  12. afterEach(cleanup)
  13. const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
  14. const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
  15. kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
  16. content: [], isError: false, subCalls: [],
  17. })
  18. function props(
  19. block: ToolCallBlock,
  20. selectedCallId?: string,
  21. home?: string,
  22. owners?: ToolCallOwnerProps[],
  23. ): ToolTreeProps {
  24. const snapshot = {} as SessionSnapshot
  25. const useSession = ((selector: (value: SessionSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
  26. const renderSlot = ((_key: string, owner: ToolCallOwnerProps, options?: { fallback?: React.ReactNode }) => {
  27. owners?.push(owner)
  28. return options?.fallback ?? null
  29. }) as unknown as ToolTreeProps['renderSlot']
  30. return {
  31. useSession,
  32. renderSlot,
  33. node: {
  34. key: `tool:${block.callId}`,
  35. kind: 'tool-call',
  36. id: block.callId,
  37. target: 'chat',
  38. anchorSeq: 'seq' in block ? block.seq : 0,
  39. location: { kind: 'session' },
  40. visibility: 'visible',
  41. data: { root: block },
  42. },
  43. selectedCallId,
  44. openFile: vi.fn(),
  45. inspectCall: vi.fn(),
  46. forkAt: vi.fn(),
  47. loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
  48. fileMentions: vi.fn(),
  49. useHostInfo: ((selector: (info: { home: string | undefined }) => unknown) => selector({ home })) as ToolTreeProps['useHostInfo'],
  50. t,
  51. } as unknown as ToolTreeProps
  52. }
  53. describe('ToolCallTree', () => {
  54. it('owns the root marker and the generic fallback 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(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
  60. expect(view.getByText('w1')).toBeTruthy()
  61. })
  62. it('renders a current-ID leaf under its historical-ID parent', () => {
  63. const owners: ToolCallOwnerProps[] = []
  64. const leaf = {
  65. ...root('unrelated:ptc:7', { name: 'read', argsRaw: '{"path":"a.ts"}' }),
  66. parentCallId: 'parent:code:1',
  67. }
  68. const child = {
  69. ...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
  70. parentCallId: 'parent',
  71. subCalls: [leaf],
  72. }
  73. const block = {
  74. ...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
  75. subCalls: [child],
  76. }
  77. const view = render(<ToolCallTree {...props(block, leaf.callId, undefined, owners)} />)
  78. const nests = view.container.querySelectorAll('[data-subcalls]')
  79. expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
  80. expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
  81. expect(view.container.querySelector('[data-chat-call-id="unrelated:ptc:7"]')).not.toBeNull()
  82. expect(nests).toHaveLength(2)
  83. expect(owners.map(owner => [owner.callId, owner.block.parentCallId ?? null])).toEqual([
  84. ['parent', null],
  85. ['parent:code:1', 'parent'],
  86. ['unrelated:ptc:7', 'parent:code:1'],
  87. ])
  88. })
  89. it('dispatches a running call by its wire name and forwards inspect', () => {
  90. const owners: ToolCallOwnerProps[] = []
  91. const block: ToolCallBlock = {
  92. callId: 'running', name: 'bash', argsRaw: '{"command":"pwd"}',
  93. turn: 1, step: 0, time: 1_000, subCalls: [],
  94. }
  95. const treeProps = props(block, undefined, undefined, owners)
  96. render(<ToolCallTree {...treeProps} />)
  97. expect(owners[0]?.toolName).toBe('bash')
  98. const inspect = owners[0]?.inspect
  99. expect(inspect).toBeDefined()
  100. inspect?.()
  101. expect(treeProps.inspectCall).toHaveBeenCalledExactlyOnceWith('running')
  102. })
  103. it('abbreviates a POSIX home path in the generic tool summary', () => {
  104. const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' })
  105. const view = render(<ToolCallTree {...props(block, 'w1', '/h')} />)
  106. expect(view.getByText('~/docs/a.ts')).toBeTruthy()
  107. })
  108. it('renders an Auto denial generically before keyed slot dispatch', () => {
  109. const block = {
  110. ...root('denied', { name: 'skill', argsRaw: '{"name":"deploy"}' }),
  111. isError: true,
  112. error: {
  113. name: 'AutoReviewDeniedError',
  114. code: 'AUTO_REVIEW_DENIED',
  115. reason: 'not authorized',
  116. },
  117. }
  118. const renderSlot = vi.fn(() => <div data-testid="keyed-skill" />)
  119. const view = render(<ToolCallTree {...props(block)} renderSlot={renderSlot as ToolTreeProps['renderSlot']} />)
  120. expect(renderSlot).not.toHaveBeenCalled()
  121. expect(view.queryByTestId('keyed-skill')).toBeNull()
  122. expect(view.getByText('Auto review 已拒绝')).toBeTruthy()
  123. })
  124. })