tool-row.client.spec.tsx 28 KB

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