The bash tool already declares the `card: 'terminal'` render intent for both its call and its result, and host/connection/runtime already deliver it to the browser as callView/resultView. The Web client ignored it: rows derived from raw args, and the details panel flattened every tool's content into one soft-wrapping `<pre>`. Column-aligned output folded into a paragraph and a long listing stretched the panel without bound. `TerminalBlock` (ui-primitives) renders a command as a terminal surface: a shortened-cwd prompt line, output at `white-space: pre` in a horizontally scrolling box, a head/tail height cap with an expand control, an exit-code/signal status pill, and a copy control for the raw output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*` theme tokens, with literal rgb kept for values the design system has no token for. Geometry and fonts mirror CodeBlock; the clipboard write both need moved into a package-internal `clipboard.ts`. Both Web render sites for a bash call consume the intent through one derivation (`terminal-card-model.ts`), so they cannot disagree about a command, its cwd, or its exit status: the keyed BashRow carries the card resident below its summary row, and the render-site fallback row keeps it behind its existing expand control. Rows cap at 8 lines against the panel's 16. Inline output in the chat row reverses this package's stated no-inline-output convention, on the owner's explicit decision; the Agent Note records the reversal and its bound. Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring specs at every render site, a built-client-graph snapshot covering both chat-row shapes, and a real-browser e2e asserting the no-wrap layout and the page's own Clipboard API.
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render } from '@testing-library/react'
|
|
|
|
afterEach(cleanup)
|
|
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { classifyTool, 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 } from '../src/client/chat/GenericToolCard.tsx'
|
|
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
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('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('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('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('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 = {
|
|
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('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
|
|
const view = render(<ToolRow {...rowProps} />)
|
|
fireEvent.click(view.container.querySelector('button')!)
|
|
expect(view.queryByTestId('tool-icon')).toBeNull()
|
|
expect(view.container.querySelector('svg')).not.toBeNull()
|
|
expect(view.queryByText('List files')).toBeNull()
|
|
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
|
fireEvent.click(view.container.querySelector('button')!)
|
|
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
|
expect(view.getByText('List files')).toBeTruthy()
|
|
})
|
|
|
|
it('running and error states replace the icon with a StateDot', () => {
|
|
const runningView = render(<ToolRow {...rowProps} state="running" />)
|
|
expect(runningView.queryByTestId('tool-icon')).toBeNull()
|
|
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
|
const errorView = render(<ToolRow {...rowProps} state="error" />)
|
|
expect(errorView.queryByTestId('tool-icon')).toBeNull()
|
|
})
|
|
|
|
it('non-expandable rows render a passive leading slot', () => {
|
|
const view = render(<ToolRow {...rowProps} body={null} />)
|
|
expect(view.container.querySelector('button')).toBeNull()
|
|
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
|
})
|
|
|
|
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
|
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
|
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('a non-expandable expandOnRowClick row exposes no row button', () => {
|
|
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
|
expect(view.queryByRole('button')).toBeNull()
|
|
})
|
|
|
|
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
|
|
const open = vi.fn()
|
|
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
|
|
fireEvent.click(view.getByText('List files'))
|
|
expect(open).toHaveBeenCalledTimes(1)
|
|
fireEvent.click(view.container.querySelector('button')!)
|
|
expect(open).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('ThinkRow', () => {
|
|
it('expands from either Think or the reasoning summary', () => {
|
|
const view = render(
|
|
<AssistantMarkdown
|
|
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')
|
|
})
|
|
})
|
|
|
|
describe('GenericToolCard', () => {
|
|
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
|
callId: 'c1', toolName, block, openDetails: vi.fn(),
|
|
})
|
|
|
|
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('row click reaches openDetails', () => {
|
|
const p = props('bash', result())
|
|
const view = render(<GenericToolCard {...p} />)
|
|
fireEvent.click(view.getByText('List files'))
|
|
expect(p.openDetails).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|