chat-tool-row.spec.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { cleanup, fireEvent, render } from '@testing-library/react'
  4. afterEach(cleanup)
  5. import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
  6. import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
  7. import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
  8. import { ToolRow } from '../src/client/chat/ToolRow.tsx'
  9. import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
  10. import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
  11. const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
  12. callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
  13. turn: 1, step: 1, time: 1_000, callView: null, ...over,
  14. })
  15. const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
  16. kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
  17. call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
  18. callTime: 1_000,
  19. content: [], isError: false, callView: null, resultView: null, ...over,
  20. })
  21. describe('tool-call-model', () => {
  22. it('classifies known tools and falls back to others', () => {
  23. expect(classifyTool('bash')).toBe('bash')
  24. expect(classifyTool('read')).toBe('read')
  25. expect(classifyTool('web_fetch')).toBe('read')
  26. expect(classifyTool('web_search')).toBe('search')
  27. expect(classifyTool('grep')).toBe('search')
  28. expect(classifyTool('write')).toBe('write')
  29. expect(classifyTool('edit')).toBe('edit')
  30. expect(classifyTool('todo_write')).toBe('others')
  31. })
  32. it('derives state across running/ok/error/interrupted', () => {
  33. expect(toolRowModel('bash', running()).state).toBe('running')
  34. expect(toolRowModel('bash', result()).state).toBe('ok')
  35. expect(toolRowModel('bash', result({ isError: true })).state).toBe('error')
  36. expect(toolRowModel('bash', result({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
  37. })
  38. it('derives the bash summary from description over command', () => {
  39. const m = toolRowModel('bash', running())
  40. expect(m.title).toBe('Bash')
  41. expect(m.summary).toBe('List files')
  42. expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
  43. })
  44. it('keeps summaries single-line and falls back for opaque args', () => {
  45. expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
  46. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
  47. expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
  48. expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
  49. // Others rows prefix the real tool name into the summary slot (figma-flows
  50. // ruling: static "Tool call" title, name rides the mutable summary).
  51. expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
  52. expect(toolRowModel('x', running({ argsRaw: 'not json' })).summary).toBe('x · not json')
  53. expect(toolRowModel('x', running({ argsRaw: '' })).summary).toBe('x · c1')
  54. expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
  55. })
  56. it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
  57. expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
  58. expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
  59. expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
  60. expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
  61. })
  62. })
  63. describe('ToolRow', () => {
  64. const rowProps = {
  65. variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
  66. summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
  67. }
  68. it('renders leading icon, title and summary while collapsed', () => {
  69. const view = render(<ToolRow {...rowProps} />)
  70. expect(view.queryByTestId('tool-icon')).not.toBeNull()
  71. expect(view.getByText('Bash')).toBeTruthy()
  72. expect(view.getByText('List files')).toBeTruthy()
  73. expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
  74. })
  75. it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
  76. const view = render(<ToolRow {...rowProps} />)
  77. fireEvent.click(view.container.querySelector('button')!)
  78. expect(view.queryByTestId('tool-icon')).toBeNull()
  79. expect(view.container.querySelector('svg')).not.toBeNull()
  80. expect(view.queryByText('List files')).toBeNull()
  81. expect(view.getByText(/"a": 1/)).toBeTruthy()
  82. fireEvent.click(view.container.querySelector('button')!)
  83. expect(view.queryByTestId('tool-icon')).not.toBeNull()
  84. expect(view.getByText('List files')).toBeTruthy()
  85. })
  86. it('running and error states replace the icon with a StateDot', () => {
  87. const runningView = render(<ToolRow {...rowProps} state="running" />)
  88. expect(runningView.queryByTestId('tool-icon')).toBeNull()
  89. expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
  90. const errorView = render(<ToolRow {...rowProps} state="error" />)
  91. expect(errorView.queryByTestId('tool-icon')).toBeNull()
  92. })
  93. it('non-expandable rows render a passive leading slot', () => {
  94. const view = render(<ToolRow {...rowProps} body={null} />)
  95. expect(view.container.querySelector('button')).toBeNull()
  96. expect(view.queryByTestId('tool-icon')).not.toBeNull()
  97. })
  98. it('row click hands off to onOpenDetails; the expand toggle does not', () => {
  99. const open = vi.fn()
  100. const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
  101. fireEvent.click(view.getByText('List files'))
  102. expect(open).toHaveBeenCalledTimes(1)
  103. fireEvent.click(view.container.querySelector('button')!)
  104. expect(open).toHaveBeenCalledTimes(1)
  105. })
  106. })
  107. describe('ThinkRow', () => {
  108. it('expands from either Think or the reasoning summary', () => {
  109. const view = render(
  110. <AssistantMarkdown
  111. blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
  112. streaming={false}
  113. />,
  114. )
  115. const row = view.getByRole('button')
  116. fireEvent.click(view.getByText('Inspect the session'))
  117. expect(row.getAttribute('aria-expanded')).toBe('true')
  118. expect(view.getByText(/Check persistence/)).toBeTruthy()
  119. fireEvent.click(view.getByText('Think'))
  120. expect(row.getAttribute('aria-expanded')).toBe('false')
  121. })
  122. })
  123. describe('GenericToolCard', () => {
  124. const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
  125. callId: 'c1', toolName, block, openDetails: vi.fn(),
  126. })
  127. it('renders the classified variant row from the frozen slice', () => {
  128. const view = render(<GenericToolCard {...props('bash', result())} />)
  129. expect(view.getByText('Bash')).toBeTruthy()
  130. expect(view.getByText('List files')).toBeTruthy()
  131. expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull()
  132. })
  133. it('unknown tools land on the others variant titled Tool call', () => {
  134. const view = render(
  135. <GenericToolCard {...props('todo_write', running({ name: 'todo_write', argsRaw: '{"note":"x"}' }))} />,
  136. )
  137. expect(view.getByText('Tool call')).toBeTruthy()
  138. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
  139. expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
  140. })
  141. it('renders edit with its dedicated title, icon variant, and path summary', () => {
  142. const view = render(
  143. <GenericToolCard {...props('edit', running({
  144. name: 'edit',
  145. argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
  146. }))} />,
  147. )
  148. expect(view.getByText('Edit')).toBeTruthy()
  149. expect(view.getByText('src/x.ts')).toBeTruthy()
  150. expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
  151. expect(view.container.querySelector('svg')).not.toBeNull()
  152. })
  153. it('renders write with its dedicated title, icon variant, and path summary', () => {
  154. const view = render(
  155. <GenericToolCard {...props('write', running({
  156. name: 'write',
  157. argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
  158. }))} />,
  159. )
  160. expect(view.getByText('Write')).toBeTruthy()
  161. expect(view.getByText('src/x.ts')).toBeTruthy()
  162. expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
  163. expect(view.container.querySelector('svg')).not.toBeNull()
  164. })
  165. it('row click reaches openDetails', () => {
  166. const p = props('bash', result())
  167. const view = render(<GenericToolCard {...p} />)
  168. fireEvent.click(view.getByText('List files'))
  169. expect(p.openDetails).toHaveBeenCalledTimes(1)
  170. })
  171. })