tool-row.client.spec.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { cleanup, fireEvent, render } from '@testing-library/react'
  4. import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
  5. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  7. import {
  8. classifyTool, formatToolBody, resultText, toolRowModel,
  9. } from '../src/client/tool/models/tool-call-model.ts'
  10. import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
  11. import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
  12. import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
  13. afterEach(() => {
  14. cleanup()
  15. vi.restoreAllMocks()
  16. })
  17. const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
  18. const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
  19. callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
  20. turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
  21. })
  22. const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
  23. kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
  24. call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
  25. callTime: 1_000,
  26. content: [], isError: false, subCalls: [], ...over,
  27. })
  28. describe('tool-call-model', () => {
  29. it('classifies known tools and falls back to others', () => {
  30. expect(classifyTool('bash')).toBe('bash')
  31. expect(classifyTool('pwsh')).toBe('bash')
  32. expect(classifyTool('read')).toBe('read')
  33. expect(classifyTool('web_fetch')).toBe('read')
  34. expect(classifyTool('web_search')).toBe('search')
  35. expect(classifyTool('grep')).toBe('search')
  36. expect(classifyTool('write')).toBe('write')
  37. expect(classifyTool('edit')).toBe('edit')
  38. expect(classifyTool('cordis_runtime_inspect')).toBe('read')
  39. // The v3 run-control verbs: `others` is the decided intent, not an
  40. // unclassified default (there is no program to show and no file to open).
  41. expect(classifyTool('cordis_run')).toBe('others')
  42. expect(classifyTool('cordis_stop')).toBe('others')
  43. expect(classifyTool('cordis_undefine')).toBe('others')
  44. expect(classifyTool('todo_write')).toBe('others')
  45. })
  46. it('names each cordis verb instead of leaving it a bare tool call', () => {
  47. // Every define/run pair the model makes puts a row in the flow, so the
  48. // generic "Tool call · cordis_run · dyn-1" fallback is user-visible slop.
  49. const titleOf = (name: string) => toolRowModel(name, running({ name, argsRaw: '{"id":"dyn-1"}' }))
  50. expect(t(titleOf('cordis_run').titleKey)).toBe('运行 Cordis 插件')
  51. expect(t(titleOf('cordis_stop').titleKey)).toBe('停止 Cordis 插件')
  52. expect(t(titleOf('cordis_undefine').titleKey)).toBe('移除 Cordis 插件')
  53. // An owned title takes the tool name out of the summary slot, leaving the
  54. // package id as the only mutable text.
  55. expect(titleOf('cordis_run').summary).toBe('dyn-1')
  56. })
  57. it('leaves cordis_define to its own keyed toolview', () => {
  58. // ui-cordis registers a keyed `tool.call.toolview` entry for cordis_define,
  59. // and a keyed hit replaces the generic row (this model is only reached
  60. // through the dispatch fallback). A mapping here would be unreachable, and a
  61. // title here would be a second answer to what the card already renders.
  62. const model = toolRowModel('cordis_define', running({ name: 'cordis_define', argsRaw: '{"name":"clock"}' }))
  63. expect(model.variant).toBe('others')
  64. expect(t(model.titleKey)).toBe('工具调用')
  65. })
  66. it('renders cordis mount verbs no shipped tool implements as generic calls', () => {
  67. // No shipped tool implements these cordis mount verbs, so a mapping would
  68. // be unreachable.
  69. expect(classifyTool('cordis_mount')).toBe('others')
  70. expect(t(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).titleKey)).toBe('工具调用')
  71. expect(t(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).titleKey)).toBe('工具调用')
  72. })
  73. it('gives the pwsh shell row the bash family treatment with its own title', () => {
  74. const m = toolRowModel('pwsh', running())
  75. expect(m.variant).toBe('bash')
  76. expect(t(m.titleKey)).toBe('Pwsh')
  77. })
  78. it('derives state across running/ok/error/interrupted', () => {
  79. expect(toolRowModel('bash', running()).state).toBe('running')
  80. expect(toolRowModel('bash', result()).state).toBe('ok')
  81. expect(toolRowModel('bash', result({ isError: true })).state).toBe('error')
  82. expect(toolRowModel('bash', result({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
  83. })
  84. it('derives the bash summary from description over command', () => {
  85. const m = toolRowModel('bash', running())
  86. expect(t(m.titleKey)).toBe('Bash')
  87. expect(m.summary).toBe('List files')
  88. expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
  89. })
  90. it('keeps summaries single-line and falls back for opaque args', () => {
  91. expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
  92. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
  93. expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
  94. expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
  95. // Other rows prefix the real tool name into the summary slot (figma
  96. // flows: static "Tool call" title, the name rides the mutable summary).
  97. expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
  98. expect(toolRowModel('x', running({ argsRaw: 'not json' })).summary).toBe('x · not json')
  99. expect(toolRowModel('x', running({ argsRaw: '' })).summary).toBe('x · c1')
  100. expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
  101. })
  102. it('joins multi-query web search arguments in the summary', () => {
  103. expect(toolRowModel('web_search', running({
  104. name: 'web_search',
  105. argsRaw: '{"queries":["first query","second\\nquery"]}',
  106. })).summary).toBe('first query, second')
  107. })
  108. it('exposes filePath for path/file_path args and skips URL-only reads', () => {
  109. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
  110. expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
  111. expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
  112. expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
  113. .toBeUndefined()
  114. expect(toolRowModel('bash', running()).filePath).toBeUndefined()
  115. })
  116. it('displays workspace-rooted paths relative to the session cwd', () => {
  117. const cwd = '/Users/u/ws/'
  118. expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
  119. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
  120. // Paths outside the workspace (and non-path summaries) stay verbatim.
  121. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
  122. expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
  123. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
  124. })
  125. it('abbreviates leftover POSIX home paths after cwd relativization', () => {
  126. const home = '/Users/u'
  127. const cwd = '/tmp/ws'
  128. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u"}' }), cwd, home).summary).toBe('~')
  129. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/notes.md"}' }), cwd, home).summary)
  130. .toBe('~/notes.md')
  131. // Workspace-relative wins: a home-and-cwd descendant stays short, not `~/…`.
  132. expect(toolRowModel(
  133. 'read',
  134. running({ name: 'read', argsRaw: '{"path":"/Users/u/proj/src/a.ts"}' }),
  135. '/Users/u/proj',
  136. home,
  137. ).summary).toBe('src/a.ts')
  138. // Prefix boundary: `/Users/u2` is not under `/Users/u`.
  139. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u2/a.ts"}' }), cwd, home).summary)
  140. .toBe('/Users/u2/a.ts')
  141. expect(toolRowModel(
  142. 'read',
  143. running({ name: 'read', argsRaw: '{"path":"C:\\\\Users\\\\u\\\\a.ts"}' }),
  144. cwd,
  145. home,
  146. ).summary).toBe('C:\\Users\\u\\a.ts')
  147. expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/a.ts"}' }), cwd).summary)
  148. .toBe('/Users/u/a.ts')
  149. })
  150. it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
  151. expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: '{"a":1}' })).bodyRaw ?? ''))
  152. .toBe('{\n "a": 1\n}')
  153. expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: 'raw' })).bodyRaw ?? ''))
  154. .toBe('raw')
  155. expect(toolRowModel('bash', running({ argsRaw: '' })).bodyRaw).toBeNull()
  156. expect(toolRowModel('bash', result({ call: null })).bodyRaw).toBeNull()
  157. })
  158. it('a code row with an empty program falls back to the args JSON envelope', () => {
  159. const model = toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' }))
  160. expect(formatToolBody(model.variant, model.bodyRaw ?? ''))
  161. .toBe('{\n "code": ""\n}')
  162. })
  163. it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
  164. expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
  165. expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
  166. .toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
  167. expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
  168. .toBe('ToolError: denied')
  169. expect(resultText(result({ content: [] }))).toBe('')
  170. })
  171. it('derives output from the settled result and null while running or blank', () => {
  172. expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
  173. expect(toolRowModel('bash', running()).output).toBeNull()
  174. expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
  175. })
  176. it('derives errorSummary as the first output line on error rows only', () => {
  177. const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
  178. expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
  179. expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
  180. expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
  181. expect(toolRowModel('bash', running()).errorSummary).toBeNull()
  182. })
  183. it('gives Cordis lifecycle tools action titles over their generic variants', () => {
  184. expect(toolRowModel('cordis_runtime_inspect', running({
  185. name: 'cordis_runtime_inspect',
  186. argsRaw: '{"what":"api","name":"tools"}',
  187. }))).toMatchObject({
  188. variant: 'read',
  189. titleKey: 'tool.title.inspect',
  190. summary: 'api',
  191. })
  192. expect(toolRowModel('cordis_run', running({
  193. name: 'cordis_run',
  194. argsRaw: '{"id":"dyn-2"}',
  195. }))).toMatchObject({
  196. variant: 'others',
  197. titleKey: 'tool.title.runCordis',
  198. summary: 'dyn-2',
  199. })
  200. expect(toolRowModel('cordis_undefine', result({
  201. call: { name: 'cordis_undefine', argsRaw: '{"id":"dyn-2"}' },
  202. }))).toMatchObject({
  203. variant: 'others',
  204. titleKey: 'tool.title.removeCordis',
  205. summary: 'dyn-2',
  206. })
  207. })
  208. })
  209. describe('ToolRow', () => {
  210. const rowProps = {
  211. t,
  212. variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
  213. summary: 'List files', bodyRaw: '{"a":1}', state: 'ok' as const,
  214. }
  215. it('renders leading icon, title and summary while collapsed', () => {
  216. const view = render(<ToolRow {...rowProps} />)
  217. expect(view.queryByTestId('tool-icon')).not.toBeNull()
  218. expect(view.getByText('Bash')).toBeTruthy()
  219. expect(view.getByText('List files')).toBeTruthy()
  220. expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
  221. })
  222. it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
  223. const view = render(<ToolRow {...rowProps} />)
  224. fireEvent.click(view.getByRole('button'))
  225. expect(view.queryByTestId('tool-icon')).toBeNull()
  226. expect(view.container.querySelector('svg')).not.toBeNull()
  227. expect(view.getByText('List files')).toBeTruthy()
  228. expect(view.getByText(/"a": 1/)).toBeTruthy()
  229. expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
  230. fireEvent.click(view.getByRole('button'))
  231. expect(view.queryByTestId('tool-icon')).not.toBeNull()
  232. expect(view.getByText('List files')).toBeTruthy()
  233. })
  234. it('formats the argument body only while expanding it', () => {
  235. const stringify = vi.spyOn(JSON, 'stringify')
  236. const bodyFormatCalls = () => stringify.mock.calls.filter(
  237. ([value, replacer, space]) => typeof value === 'object'
  238. && value !== null
  239. && 'a' in value
  240. && (value as { a?: unknown }).a === 1
  241. && replacer === null
  242. && space === 2,
  243. ).length
  244. const view = render(<ToolRow {...rowProps} />)
  245. expect(bodyFormatCalls()).toBe(0)
  246. fireEvent.click(view.getByRole('button'))
  247. expect(bodyFormatCalls()).toBe(1)
  248. expect(view.getByText(/"a": 1/)).toBeTruthy()
  249. fireEvent.click(view.getByRole('button'))
  250. expect(bodyFormatCalls()).toBe(1)
  251. expect(view.queryByText(/"a": 1/)).toBeNull()
  252. })
  253. it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
  254. const runningView = render(<ToolRow {...rowProps} state="running" />)
  255. expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
  256. expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
  257. const errorView = render(<ToolRow {...rowProps} state="error" />)
  258. expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
  259. // The dot rides the idle slot, so an expandable error row keeps the
  260. // icon→chevron hover preview instead of losing it with the icon.
  261. expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
  262. })
  263. it('non-expandable rows render a passive leading slot and no row button', () => {
  264. const view = render(<ToolRow {...rowProps} bodyRaw={null} />)
  265. expect(view.queryByRole('button')).toBeNull()
  266. expect(view.container.querySelector('[aria-expanded]')).toBeNull()
  267. expect(view.queryByTestId('tool-icon')).not.toBeNull()
  268. })
  269. it('the row toggles from Enter and Space, ignoring other keys', () => {
  270. const view = render(<ToolRow {...rowProps} />)
  271. const row = view.getByRole('button')
  272. fireEvent.keyDown(row, { key: 'Tab' })
  273. expect(row.getAttribute('aria-expanded')).toBe('false')
  274. fireEvent.keyDown(row, { key: 'Enter' })
  275. expect(row.getAttribute('aria-expanded')).toBe('true')
  276. fireEvent.keyDown(row, { key: ' ' })
  277. expect(row.getAttribute('aria-expanded')).toBe('false')
  278. })
  279. it('file rows expand from the row while the path link opens without toggling', () => {
  280. const open = vi.fn()
  281. const view = render(
  282. <ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
  283. )
  284. const row = view.getByRole('button', { name: /Read/ })
  285. // Path click opens the file and leaves the row collapsed.
  286. fireEvent.click(view.getByText('src/a.ts'))
  287. expect(open).toHaveBeenCalledWith('src/a.ts')
  288. expect(row.getAttribute('aria-expanded')).toBe('false')
  289. // Row click (outside the link) expands the args body.
  290. fireEvent.click(row)
  291. expect(row.getAttribute('aria-expanded')).toBe('true')
  292. expect(view.getByText(/"a": 1/)).toBeTruthy()
  293. })
  294. it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
  295. const view = render(
  296. <ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
  297. )
  298. expect(view.container.querySelector('button')).toBeNull()
  299. const row = view.getByRole('button')
  300. fireEvent.click(row)
  301. expect(row.getAttribute('aria-expanded')).toBe('true')
  302. expect(view.getByText(/"a": 1/)).toBeTruthy()
  303. })
  304. it('non-file rows do not open anything when the summary is clicked', () => {
  305. const open = vi.fn()
  306. const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
  307. fireEvent.click(view.getByText('List files'))
  308. expect(open).not.toHaveBeenCalled()
  309. })
  310. it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
  311. const view = render(
  312. <ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
  313. )
  314. expect(view.getByText('boom')).toBeTruthy()
  315. expect(view.queryByText('List files')).toBeNull()
  316. fireEvent.click(view.getByRole('button'))
  317. expect(view.getByText(/detail/)).toBeTruthy()
  318. expect(view.container.querySelector('[data-error]')).not.toBeNull()
  319. })
  320. it('an error row without an error summary keeps the args summary', () => {
  321. const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
  322. expect(view.getByText('List files')).toBeTruthy()
  323. })
  324. it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
  325. const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
  326. const summary = view.getByText('List files')
  327. const suffix = view.getByText('+2')
  328. // Separate spans: .summary truncates, the suffix must not travel inside it.
  329. expect(summary.contains(suffix)).toBe(false)
  330. view.unmount()
  331. // The failure line replaces the summary wholesale, so the suffix goes with it.
  332. const failed = render(
  333. <ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />,
  334. )
  335. expect(failed.queryByText('+2')).toBeNull()
  336. })
  337. it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
  338. const open = vi.fn()
  339. const view = render(
  340. <ToolRow
  341. {...rowProps}
  342. variant="write" title="Write" state="error" errorSummary="cannot overwrite"
  343. filePath="src/a.ts" onOpenFile={open}
  344. />,
  345. )
  346. fireEvent.click(view.getByText('cannot overwrite'))
  347. expect(open).not.toHaveBeenCalled()
  348. // The failure line renders as plain text, not the underlined link button.
  349. expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
  350. })
  351. it('the expanded body carries a hover Inspect pill that fires the callback', () => {
  352. const inspect = vi.fn()
  353. const view = render(<ToolRow {...rowProps} inspect={inspect} />)
  354. // Collapsed: no pill.
  355. expect(view.queryByText('查看')).toBeNull()
  356. fireEvent.click(view.getByRole('button', { name: /Bash/ }))
  357. const pill = view.getByText('查看')
  358. fireEvent.click(pill)
  359. expect(inspect).toHaveBeenCalledTimes(1)
  360. // The pill click must not collapse the row (body is a .row sibling).
  361. expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
  362. })
  363. it('no inspect callback, no pill', () => {
  364. const view = render(<ToolRow {...rowProps} />)
  365. fireEvent.click(view.getByRole('button'))
  366. expect(view.queryByText('查看')).toBeNull()
  367. })
  368. it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
  369. const both = render(<ToolRow {...rowProps} output="result text" />)
  370. fireEvent.click(both.getByRole('button'))
  371. expect(both.getByText('输入')).toBeTruthy()
  372. expect(both.getByText('输出')).toBeTruthy()
  373. expect(both.getByText('result text')).toBeTruthy()
  374. cleanup()
  375. const inputOnly = render(<ToolRow {...rowProps} />)
  376. fireEvent.click(inputOnly.getByRole('button'))
  377. expect(inputOnly.getByText('输入')).toBeTruthy()
  378. expect(inputOnly.queryByText('输出')).toBeNull()
  379. cleanup()
  380. const outputOnly = render(<ToolRow {...rowProps} bodyRaw={null} output="only out" />)
  381. fireEvent.click(outputOnly.getByRole('button'))
  382. expect(outputOnly.queryByText('输入')).toBeNull()
  383. expect(outputOnly.getByText('输出')).toBeTruthy()
  384. expect(outputOnly.getByText('only out')).toBeTruthy()
  385. })
  386. })
  387. describe('GenericToolCard', () => {
  388. const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
  389. loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
  390. callId: 'c1', toolName, block, openFile: vi.fn(), t,
  391. })
  392. it('renders the classified variant row from the frozen slice', () => {
  393. const view = render(<GenericToolCard {...props('bash', result())} />)
  394. expect(view.getByText('Bash')).toBeTruthy()
  395. expect(view.getByText('List files')).toBeTruthy()
  396. expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull()
  397. })
  398. it('unknown tools land on the others variant titled Tool call', () => {
  399. const view = render(
  400. <GenericToolCard {...props('todo_write', running({ name: 'todo_write', argsRaw: '{"note":"x"}' }))} />,
  401. )
  402. expect(view.getByText('工具调用')).toBeTruthy()
  403. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
  404. expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
  405. })
  406. it('renders edit with its dedicated title, icon variant, and path summary', () => {
  407. const view = render(
  408. <GenericToolCard {...props('edit', running({
  409. name: 'edit',
  410. argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
  411. }))} />,
  412. )
  413. expect(view.getByText('编辑')).toBeTruthy()
  414. expect(view.getByText('src/x.ts')).toBeTruthy()
  415. expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
  416. expect(view.container.querySelector('svg')).not.toBeNull()
  417. })
  418. it('renders write with its dedicated title, icon variant, and path summary', () => {
  419. const view = render(
  420. <GenericToolCard {...props('write', running({
  421. name: 'write',
  422. argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
  423. }))} />,
  424. )
  425. expect(view.getByText('写入')).toBeTruthy()
  426. expect(view.getByText('src/x.ts')).toBeTruthy()
  427. expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
  428. expect(view.container.querySelector('svg')).not.toBeNull()
  429. })
  430. it('passes the owner inspect callback through to the expanded row pill', () => {
  431. const inspect = vi.fn()
  432. const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
  433. fireEvent.click(view.getByRole('button', { name: /Bash/ }))
  434. fireEvent.click(view.getByText('查看'))
  435. expect(inspect).toHaveBeenCalledTimes(1)
  436. })
  437. it('file-path summary click reaches openFile; bash summary does not', () => {
  438. const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
  439. const fileView = render(<GenericToolCard {...file} />)
  440. fireEvent.click(fileView.getByText('src/x.ts'))
  441. expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
  442. const bash = props('bash', result())
  443. const bashView = render(<GenericToolCard {...bash} />)
  444. fireEvent.click(bashView.getByText('List files'))
  445. expect(bash.openFile).not.toHaveBeenCalled()
  446. })
  447. })