mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
A completed pwsh foreground call now presents as the bash tool's terminal card with the parsed exit-status pill instead of a generic console fence, and the collapsed row classifies as the shell family (Pwsh title). The marker-to-exit-status parse moves from dsh-tool-bash's private render module into the @deepseek-ai/dsh-bash seam so both shell tools share one inverse of the marker contract (the bash-env precedent). The Web UI needs no per-tool code: the terminal-card bridge maps any card:'terminal' view. Coverage: dsh-bash owns the parse edge cases; the tool-pwsh presenter suite mirrors tool-bash's; the client row-model suite pins the Pwsh shell row; the new keyless pwsh-terminal web lane seeds an authored session, presents it through the real tool on replay, and pins the card golden. TUI is out of scope: the TUI package was removed (ed30088adb), so the Web surface is the only UI the gap affected; the roadmap proposal's stage 2 is updated accordingly.
502 lines
22 KiB
TypeScript
502 lines
22 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render } from '@testing-library/react'
|
|
|
|
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
|
import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
|
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
|
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
|
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
|
let nextAnimationFrameId = 1
|
|
let animationFrames = new Map<number, FrameRequestCallback>()
|
|
|
|
function flushAnimationFrames(count: number): void {
|
|
for (let index = 0; index < count; index += 1) {
|
|
const callbacks = [...animationFrames.values()]
|
|
animationFrames.clear()
|
|
for (const callback of callbacks) callback(index)
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
nextAnimationFrameId = 1
|
|
animationFrames = new Map()
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
const id = nextAnimationFrameId
|
|
nextAnimationFrameId += 1
|
|
animationFrames.set(id, callback)
|
|
return id
|
|
})
|
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
|
animationFrames.delete(id)
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
// Mirrors the real lookup chain (conversation namespace, then common).
|
|
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
|
|
|
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
|
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
|
|
turn: 1, step: 1, time: 1_000, callView: null, ...over,
|
|
})
|
|
|
|
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
|
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
|
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
|
callTime: 1_000,
|
|
content: [], isError: false, callView: null, resultView: null, ...over,
|
|
})
|
|
|
|
describe('tool-call-model', () => {
|
|
it('classifies known tools and falls back to others', () => {
|
|
expect(classifyTool('bash')).toBe('bash')
|
|
expect(classifyTool('pwsh')).toBe('bash')
|
|
expect(classifyTool('read')).toBe('read')
|
|
expect(classifyTool('web_fetch')).toBe('read')
|
|
expect(classifyTool('web_search')).toBe('search')
|
|
expect(classifyTool('grep')).toBe('search')
|
|
expect(classifyTool('write')).toBe('write')
|
|
expect(classifyTool('edit')).toBe('edit')
|
|
expect(classifyTool('cordis_inspect')).toBe('read')
|
|
expect(classifyTool('cordis_mount')).toBe('code')
|
|
expect(classifyTool('cordis_unmount')).toBe('others')
|
|
expect(classifyTool('todo_write')).toBe('others')
|
|
})
|
|
|
|
it('gives the pwsh shell row the bash family treatment with its own title', () => {
|
|
const m = toolRowModel('pwsh', running())
|
|
expect(m.variant).toBe('bash')
|
|
expect(m.title).toBe('Pwsh')
|
|
})
|
|
|
|
it('derives state across running/ok/error/interrupted', () => {
|
|
expect(toolRowModel('bash', running()).state).toBe('running')
|
|
expect(toolRowModel('bash', result()).state).toBe('ok')
|
|
expect(toolRowModel('bash', result({ isError: true })).state).toBe('error')
|
|
expect(toolRowModel('bash', result({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
|
|
})
|
|
|
|
it('derives the bash summary from description over command', () => {
|
|
const m = toolRowModel('bash', running())
|
|
expect(m.title).toBe('Bash')
|
|
expect(m.summary).toBe('List files')
|
|
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
|
|
})
|
|
|
|
it('keeps summaries single-line and falls back for opaque args', () => {
|
|
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
|
|
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
|
|
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
|
|
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
|
|
// Others rows prefix the real tool name into the summary slot (figma-flows
|
|
// ruling: static "Tool call" title, name rides the mutable summary).
|
|
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
|
|
expect(toolRowModel('x', running({ argsRaw: 'not json' })).summary).toBe('x · not json')
|
|
expect(toolRowModel('x', running({ argsRaw: '' })).summary).toBe('x · c1')
|
|
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
|
|
})
|
|
|
|
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
|
|
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
|
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
|
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
|
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
|
|
.toBeUndefined()
|
|
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
|
|
})
|
|
|
|
it('resolveToolPath joins relative paths under cwd and passes absolute through', () => {
|
|
expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
|
|
expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
|
|
expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts')
|
|
expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
|
|
})
|
|
|
|
it('displays workspace-rooted paths relative to the session cwd', () => {
|
|
const cwd = '/Users/u/ws/'
|
|
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
|
|
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
|
|
// Paths outside the workspace (and non-path summaries) stay verbatim.
|
|
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
|
|
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
|
|
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
|
|
})
|
|
|
|
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
|
|
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
|
|
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
|
|
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
|
|
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
|
})
|
|
|
|
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
|
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
|
.toBe('{\n "code": ""\n}')
|
|
})
|
|
|
|
it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
|
|
expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
|
|
expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
|
|
.toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
|
|
expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
|
|
.toBe('ToolError: denied')
|
|
expect(resultText(result({ content: [] }))).toBe('')
|
|
})
|
|
|
|
it('derives output from the settled result and null while running or blank', () => {
|
|
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
|
|
expect(toolRowModel('bash', running()).output).toBeNull()
|
|
expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
|
|
})
|
|
|
|
it('derives errorSummary as the first output line on error rows only', () => {
|
|
const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
|
|
expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
|
|
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
|
|
expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
|
|
expect(toolRowModel('bash', running()).errorSummary).toBeNull()
|
|
})
|
|
|
|
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
|
expect(toolRowModel('cordis_inspect', running({
|
|
name: 'cordis_inspect',
|
|
argsRaw: '{"what":"api","name":"tools"}',
|
|
}))).toMatchObject({
|
|
variant: 'read',
|
|
title: 'Inspect',
|
|
summary: 'api',
|
|
})
|
|
expect(toolRowModel('cordis_mount', running({
|
|
name: 'cordis_mount',
|
|
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
|
|
}))).toMatchObject({
|
|
variant: 'code',
|
|
title: 'Mount temporary Plugin',
|
|
summary: 'return { name: "audit", apply(ctx) {} }',
|
|
body: 'return { name: "audit", apply(ctx) {} }',
|
|
})
|
|
expect(toolRowModel('cordis_unmount', result({
|
|
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
|
|
}))).toMatchObject({
|
|
variant: 'others',
|
|
title: 'Unmount temporary Plugin',
|
|
summary: 'dyn-2',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('ToolRow', () => {
|
|
const rowProps = {
|
|
t,
|
|
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
|
|
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
|
|
}
|
|
|
|
it('renders leading icon, title and summary while collapsed', () => {
|
|
const view = render(<ToolRow {...rowProps} />)
|
|
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
|
expect(view.getByText('Bash')).toBeTruthy()
|
|
expect(view.getByText('List files')).toBeTruthy()
|
|
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
|
|
})
|
|
|
|
it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
|
|
const view = render(<ToolRow {...rowProps} />)
|
|
fireEvent.click(view.getByRole('button'))
|
|
expect(view.queryByTestId('tool-icon')).toBeNull()
|
|
expect(view.container.querySelector('svg')).not.toBeNull()
|
|
expect(view.getByText('List files')).toBeTruthy()
|
|
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
|
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
|
fireEvent.click(view.getByRole('button'))
|
|
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
|
expect(view.getByText('List files')).toBeTruthy()
|
|
})
|
|
|
|
it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
|
|
const runningView = render(<ToolRow {...rowProps} state="running" />)
|
|
expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
|
|
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
|
const errorView = render(<ToolRow {...rowProps} state="error" />)
|
|
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
|
|
// The dot rides the idle slot, so an expandable error row keeps the
|
|
// icon→chevron hover preview instead of losing it with the icon.
|
|
expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
|
|
})
|
|
|
|
it('non-expandable rows render a passive leading slot and no row button', () => {
|
|
const view = render(<ToolRow {...rowProps} body={null} />)
|
|
expect(view.queryByRole('button')).toBeNull()
|
|
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
|
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
|
})
|
|
|
|
it('the row toggles from Enter and Space, ignoring other keys', () => {
|
|
const view = render(<ToolRow {...rowProps} />)
|
|
const row = view.getByRole('button')
|
|
fireEvent.keyDown(row, { key: 'Tab' })
|
|
expect(row.getAttribute('aria-expanded')).toBe('false')
|
|
fireEvent.keyDown(row, { key: 'Enter' })
|
|
expect(row.getAttribute('aria-expanded')).toBe('true')
|
|
fireEvent.keyDown(row, { key: ' ' })
|
|
expect(row.getAttribute('aria-expanded')).toBe('false')
|
|
})
|
|
|
|
it('file rows expand from the row while the path link opens without toggling', () => {
|
|
const open = vi.fn()
|
|
const view = render(
|
|
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
|
|
)
|
|
const row = view.getByRole('button', { name: /Read/ })
|
|
// Path click opens the file and leaves the row collapsed.
|
|
fireEvent.click(view.getByText('src/a.ts'))
|
|
expect(open).toHaveBeenCalledWith('src/a.ts')
|
|
expect(row.getAttribute('aria-expanded')).toBe('false')
|
|
// Row click (outside the link) expands the args body.
|
|
fireEvent.click(row)
|
|
expect(row.getAttribute('aria-expanded')).toBe('true')
|
|
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
|
})
|
|
|
|
it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
|
|
const view = render(
|
|
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
|
|
)
|
|
expect(view.container.querySelector('button')).toBeNull()
|
|
const row = view.getByRole('button')
|
|
fireEvent.click(row)
|
|
expect(row.getAttribute('aria-expanded')).toBe('true')
|
|
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
|
})
|
|
|
|
it('non-file rows do not open anything when the summary is clicked', () => {
|
|
const open = vi.fn()
|
|
const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
|
|
fireEvent.click(view.getByText('List files'))
|
|
expect(open).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
|
|
const view = render(
|
|
<ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
|
|
)
|
|
expect(view.getByText('boom')).toBeTruthy()
|
|
expect(view.queryByText('List files')).toBeNull()
|
|
fireEvent.click(view.getByRole('button'))
|
|
expect(view.getByText(/detail/)).toBeTruthy()
|
|
expect(view.container.querySelector('[data-error]')).not.toBeNull()
|
|
})
|
|
|
|
it('an error row without an error summary keeps the args summary', () => {
|
|
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
|
|
expect(view.getByText('List files')).toBeTruthy()
|
|
})
|
|
|
|
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
|
|
const open = vi.fn()
|
|
const view = render(
|
|
<ToolRow
|
|
{...rowProps}
|
|
variant="write" title="Write" state="error" errorSummary="cannot overwrite"
|
|
filePath="src/a.ts" onOpenFile={open}
|
|
/>,
|
|
)
|
|
fireEvent.click(view.getByText('cannot overwrite'))
|
|
expect(open).not.toHaveBeenCalled()
|
|
// The failure line renders as plain text, not the underlined link button.
|
|
expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
|
|
})
|
|
|
|
it('the expanded body carries a hover Inspect pill that fires the callback', () => {
|
|
const inspect = vi.fn()
|
|
const view = render(<ToolRow {...rowProps} inspect={inspect} />)
|
|
// Collapsed: no pill.
|
|
expect(view.queryByText('Inspect')).toBeNull()
|
|
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
|
const pill = view.getByText('Inspect')
|
|
fireEvent.click(pill)
|
|
expect(inspect).toHaveBeenCalledTimes(1)
|
|
// The pill click must not collapse the row (body is a .row sibling).
|
|
expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
|
|
})
|
|
|
|
it('no inspect callback, no pill', () => {
|
|
const view = render(<ToolRow {...rowProps} />)
|
|
fireEvent.click(view.getByRole('button'))
|
|
expect(view.queryByText('Inspect')).toBeNull()
|
|
})
|
|
|
|
it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
|
|
const both = render(<ToolRow {...rowProps} output="result text" />)
|
|
fireEvent.click(both.getByRole('button'))
|
|
expect(both.getByText('IN')).toBeTruthy()
|
|
expect(both.getByText('OUT')).toBeTruthy()
|
|
expect(both.getByText('result text')).toBeTruthy()
|
|
cleanup()
|
|
const inputOnly = render(<ToolRow {...rowProps} />)
|
|
fireEvent.click(inputOnly.getByRole('button'))
|
|
expect(inputOnly.getByText('IN')).toBeTruthy()
|
|
expect(inputOnly.queryByText('OUT')).toBeNull()
|
|
cleanup()
|
|
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
|
|
fireEvent.click(outputOnly.getByRole('button'))
|
|
expect(outputOnly.queryByText('IN')).toBeNull()
|
|
expect(outputOnly.getByText('OUT')).toBeTruthy()
|
|
expect(outputOnly.getByText('only out')).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('ThinkRow', () => {
|
|
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
|
|
const view = render(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
|
|
streaming
|
|
/>,
|
|
)
|
|
const summary = view.getByText('Newest reasoning tokens')
|
|
Object.defineProperties(summary, {
|
|
scrollWidth: { configurable: true, value: 300 },
|
|
clientWidth: { configurable: true, value: 100 },
|
|
})
|
|
|
|
view.rerender(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
|
|
streaming
|
|
/>,
|
|
)
|
|
expect(summary.scrollLeft).toBe(0)
|
|
flushAnimationFrames(2)
|
|
expect(summary.scrollLeft).toBe(0)
|
|
flushAnimationFrames(1)
|
|
expect(summary.scrollLeft).toBe(200)
|
|
expect(summary.getAttribute('data-follow-end')).toBe('true')
|
|
|
|
view.rerender(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
|
|
streaming={false}
|
|
/>,
|
|
)
|
|
flushAnimationFrames(3)
|
|
expect(view.getByText('Inspect the session')).toBeTruthy()
|
|
expect(summary.scrollLeft).toBe(0)
|
|
expect(summary.hasAttribute('data-follow-end')).toBe(false)
|
|
})
|
|
|
|
it('expands from either Think or the reasoning summary', () => {
|
|
const view = render(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
|
streaming={false}
|
|
/>,
|
|
)
|
|
const row = view.getByRole('button')
|
|
|
|
fireEvent.click(view.getByText('Inspect the session'))
|
|
expect(row.getAttribute('aria-expanded')).toBe('true')
|
|
expect(view.getByText(/Check persistence/)).toBeTruthy()
|
|
|
|
fireEvent.click(view.getByText('Think'))
|
|
expect(row.getAttribute('aria-expanded')).toBe('false')
|
|
})
|
|
|
|
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
|
|
const view = render(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
|
streaming={false}
|
|
/>,
|
|
)
|
|
fireEvent.click(view.getByText('Think'))
|
|
// The summary (first line) is gone from the row; only the body carries it.
|
|
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
|
|
expect(view.queryByText('IN')).toBeNull()
|
|
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
|
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('GenericToolCard', () => {
|
|
const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
|
callId: 'c1', toolName, block, openFile: vi.fn(), t,
|
|
})
|
|
|
|
it('renders the classified variant row from the frozen slice', () => {
|
|
const view = render(<GenericToolCard {...props('bash', result())} />)
|
|
expect(view.getByText('Bash')).toBeTruthy()
|
|
expect(view.getByText('List files')).toBeTruthy()
|
|
expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull()
|
|
})
|
|
|
|
it('unknown tools land on the others variant titled Tool call', () => {
|
|
const view = render(
|
|
<GenericToolCard {...props('todo_write', running({ name: 'todo_write', argsRaw: '{"note":"x"}' }))} />,
|
|
)
|
|
expect(view.getByText('Tool call')).toBeTruthy()
|
|
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
|
|
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
|
})
|
|
|
|
it('renders edit with its dedicated title, icon variant, and path summary', () => {
|
|
const view = render(
|
|
<GenericToolCard {...props('edit', running({
|
|
name: 'edit',
|
|
argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
|
|
}))} />,
|
|
)
|
|
expect(view.getByText('Edit')).toBeTruthy()
|
|
expect(view.getByText('src/x.ts')).toBeTruthy()
|
|
expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
|
|
expect(view.container.querySelector('svg')).not.toBeNull()
|
|
})
|
|
|
|
it('renders write with its dedicated title, icon variant, and path summary', () => {
|
|
const view = render(
|
|
<GenericToolCard {...props('write', running({
|
|
name: 'write',
|
|
argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
|
|
}))} />,
|
|
)
|
|
expect(view.getByText('Write')).toBeTruthy()
|
|
expect(view.getByText('src/x.ts')).toBeTruthy()
|
|
expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
|
|
expect(view.container.querySelector('svg')).not.toBeNull()
|
|
})
|
|
|
|
it('passes the owner inspect callback through to the expanded row pill', () => {
|
|
const inspect = vi.fn()
|
|
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
|
|
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
|
fireEvent.click(view.getByText('Inspect'))
|
|
expect(inspect).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('file-path summary click reaches openFile; bash summary does not', () => {
|
|
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
|
|
const fileView = render(<GenericToolCard {...file} />)
|
|
fireEvent.click(fileView.getByText('src/x.ts'))
|
|
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
|
|
|
|
const bash = props('bash', result())
|
|
const bashView = render(<GenericToolCard {...bash} />)
|
|
fireEvent.click(bashView.getByText('List files'))
|
|
expect(bash.openFile).not.toHaveBeenCalled()
|
|
})
|
|
})
|