perf(web): defer tool body formatting until expansion

This commit is contained in:
imccyu
2026-08-31 15:44:22 +08:00
parent 8478de9b0e
commit 61da6fbbe5
18 changed files with 98 additions and 49 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
2026-08-08-client-tool-presentation-ownership.md: 1daad1559a6c8ef15fadb8e7c8dfeb2874ae3f9a
2026-08-08-client-tool-presentation-ownership.zh.md: f980db28e1174aa95b29defb8b0a36fc0ba4cf2e
2026-08-08-client-tool-presentation-ownership.md: bf8568150cc173f0dc46ba0a125ca9c784d18f2e
2026-08-08-client-tool-presentation-ownership.zh.md: 015a48a0499b84bf034dc905780eca0dcef99c10
@@ -22,6 +22,8 @@ A business Tool plugin receives one standard `ToolCallBlock`, identity, workspac
The details panel is a second Tool presentation point, not the call-tree owner. `ui-conversation` locates the selected call and delegates its output body through `'conversation.details.tool'`; `ui-tool` reuses the card model, while the conversation fallback retains raw result text when the plugin is absent.
Generic row models retain the original argument string as `bodyRaw` and expose no preformatted body. `ToolRow` and the Bash fallback format it only while an expanded generic input section is visible; closing the row removes the formatted text, and rows rendering a structured card skip generic-body formatting.
## Runtime and render path
```text
@@ -33,6 +35,8 @@ Session Event window
-> tool.call.toolview(entryKey = toolName)
|- registered atomic view
`- GenericToolCard fallback
|- collapsed or structured card: retain argsRaw only
`- expanded generic input: format argsRaw
```
## Ownership boundary
@@ -42,12 +46,12 @@ Session Event window
| Client Runtime Conversation engine | Context identity, Location, history replay, view Node publication | Tool event meaning, call tree, Tool renderer |
| `ui-conversation` Tool Definition | call/result pairing, Code Dispatch topology, running/settled/interrupted `ToolCallBlock`, Chat ordering anchor | Tool-name dispatch, card models, recursive React structure |
| `ui-conversation` Chat view | keyed Node order, scroll anchors, selection, and host actions | Tool lifecycle, subcall composition, atomic Tool renderers |
| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, and details output | Session Event fold, Chat ordering |
| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, expansion-time argument formatting, and details output | Session Event fold, Chat ordering |
| Business Tool plugin | atomic renderers for one or more wire Tool names | root/subcall placement, lifecycle pairing, Session projectors |
## Verification
`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, and concrete Tool cards. Assembled Web tests cover the path with both plugins loaded.
`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, concrete Tool cards, and expansion-only generic-body formatting. Assembled Web tests cover the path with both plugins loaded.
## Alternatives considered
@@ -61,8 +65,12 @@ Session Event window
**Let `ui-conversation` import `ui-tool` components directly.** Rejected: this would reverse the feature dependency and make Tool presentation mandatory. Slots preserve independent loading, lifecycle, and fallback behavior.
**Keep a preformatted body on the row model for compatibility.** Rejected: every collapsed row would retain a second full argument string, and the compatibility field would let future consumers restore eager formatting. The model exposes only `bodyRaw`, making expansion-time formatting the only generic path.
## Consequences
`ui-conversation` no longer depends on presentation for concrete Tool names, and root and subcalls cannot drift onto different dispatch paths. Business packages can independently own atomic Tool renderers; if `ui-tool` is absent, Conversation data assembly remains valid, Chat Nodes use the generic fallback, and details retain raw results.
Collapsed Tool rows retain the existing `argsRaw` reference without a pretty-printed copy or its formatting call. Expanding a generic input performs that work for the visible row, and closing it permits the derived text to be collected; repeated expansion trades bounded recomputation for lower retained memory.
The cost is an explicit dependency from `ui-tool` on the business Node slot and locale namespace declared by conversation, plus one Tool-specific child slot. Tool Definition remains in `ui-conversation` because this change does not split packages; it can later move through the Conversation registry seam without changing the presentation ownership recorded here.
@@ -22,6 +22,8 @@ Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-
details panel 是第二个工具展示点,但不是调用树所有者。`ui-conversation` 定位 selected call,并通过 `'conversation.details.tool'` 委托 output body`ui-tool` 复用 card model,插件缺席时 conversation fallback 保留 raw result text。
Generic row model 保留原始参数字符串 `bodyRaw`,不暴露预格式化 body。`ToolRow` 与 Bash fallback 只在展开后的 generic input section 可见时格式化该字符串;收起行会移除格式化文本,渲染结构化卡片的行则跳过 generic body 格式化。
## 运行时与渲染路径
```text
@@ -33,6 +35,8 @@ Session Event window
-> tool.call.toolview(entryKey = toolName)
|- registered atomic view
`- GenericToolCard fallback
|- collapsed or structured card: retain argsRaw only
`- expanded generic input: format argsRaw
```
## 所有权边界
@@ -42,12 +46,12 @@ Session Event window
| Client 运行时 Conversation engine | 上下文 identity、Location、历史回放、view Node 发布 | 工具事件含义、调用树、工具 renderer |
| `ui-conversation` 工具 Definition | call/result 配对、Code Dispatch 拓扑、running/settled/interrupted `ToolCallBlock`、Chat 排序 anchor | 工具名称分发、card model、递归 React 结构 |
| `ui-conversation` Chat view | keyed Node 顺序、scroll anchor、selection 与宿主动作 | 工具 lifecycle、subcall 组合、原子工具 renderer |
| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model 与 details output | 会话事件 fold、Chat 排序 |
| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model、展开时参数格式化与 details output | 会话事件 fold、Chat 排序 |
| 业务工具插件 | 一个或多个 wire 工具名称的原子 renderer | root/subcall 位置、生命周期配对、会话 projector |
## 验证
`ui-conversation` 测试固定工具 Definition 的 call/result 配对、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details具体工具 card。组装后的 Web 测试覆盖两个插件共同装载的路径。
`ui-conversation` 测试固定工具 Definition 的 call/result 配对、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details具体工具 card 与只在展开时执行的 generic body 格式化。组装后的 Web 测试覆盖两个插件共同装载的路径。
## 考虑过的替代方案
@@ -61,8 +65,12 @@ Session Event window
**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转功能依赖并把工具展示变成必选能力。slot 保留独立装载、生命周期和 fallback。
**为兼容性在 row model 上保留预格式化 body。** 拒绝:每个折叠行都会保留第二份完整参数字符串,而且兼容字段会让后续消费方恢复 eager 格式化。model 只暴露 `bodyRaw`,使展开时格式化成为唯一 generic 路径。
## 后果
`ui-conversation` 不再依赖工具名称对应的业务展示,root 与 subcall 也不会漂移到不同分发路径。业务包可以独立拥有原子工具 renderer;`ui-tool` 缺席时,Conversation 数据组装仍然成立,Chat Node 使用通用 fallbackdetails 保留 raw result。
折叠的工具行只保留既有 `argsRaw` 引用,不创建 pretty-print 副本,也不执行对应的格式化调用。展开 generic input 时才为当前可见行完成这项工作,收起后派生文本可以被回收;重复展开以有界重算换取更低的常驻内存。
代价是 `ui-tool` 明确依赖 conversation 声明的业务 Node slot 和 locale namespace,并拥有一个工具专属子 slot。工具 Definition 暂时位于 `ui-conversation`,因为本次没有拆包;它以后可以沿 Conversation 注册表 seam 移动,而不会改变本记录规定的展示所有权。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100
README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa
README.md: 895274748d8ee5b7bece16e5b76836966cc52663
README.zh.md: f4cf11cabdb94f45c9b11e7f7538ad5042a791e6
+1 -1
View File
@@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
### Details and cards
The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Generic rows retain the original `argsRaw` reference and format their input body only while it is expanded; structured cards skip generic-body formatting. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
</details>
+1 -1
View File
@@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps``callId`、`toolName`、冻结的 `block`
### 详情与卡片
本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。Generic row 保留原始 `argsRaw` 引用,只在展开期间格式化 input body;结构化卡片跳过 generic body 格式化。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
</details>
@@ -15,7 +15,9 @@ import {
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
} from '../models/primitive-labels.ts'
import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts'
import {
formatToolBody, type ToolRowState, type ToolRowVariant,
} from '../models/tool-call-model.ts'
import type { WebCardModelProps } from '../models/web-card-model.ts'
import { AskQuestionCard } from './AskQuestionCard.tsx'
import css from './ToolRow.module.css'
@@ -36,8 +38,8 @@ export interface ToolRowProps {
* error row, whose collapsed summary is the failure line instead.
*/
summarySuffix?: string | null | undefined
/** Expanded-body input text; null = no input section. */
body: string | null
/** Original argument JSON formatted only while the row is expanded. */
bodyRaw?: string | null | undefined
/** Flattened result text for the expanded Output section; null/absent = no output section. */
output?: string | null | undefined
/** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */
@@ -94,7 +96,7 @@ export function ToolRow({
title,
summary,
summarySuffix,
body,
bodyRaw,
output,
askQuestion,
errorSummary,
@@ -124,8 +126,12 @@ export function ToolRow({
const askQuestionBody = askQuestion ?? null
const outputText = output ?? null
const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
const expandable = body !== null || outputText !== null || card !== null
const expandable = bodyRaw != null || outputText !== null || card !== null
const open = expanded && expandable
const bodyText = useMemo(
() => open && card === null && bodyRaw != null ? formatToolBody(variant, bodyRaw) : null,
[bodyRaw, card, open, variant],
)
const status = stateStatus(state, t)
// A failure must replace, not supplement, the normal summary.
const failureLine = state === 'error' ? errorSummary ?? null : null
@@ -156,7 +162,7 @@ export function ToolRow({
}
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
const cardBody = variant === 'code' ? null : bodyText
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
@@ -235,9 +241,9 @@ export function ToolRow({
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
: (
<>
{variant === 'code' && body !== null && (
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
@@ -1,9 +1,9 @@
/**
* Pure row-model derivation for tool summary rows: variant classification,
* one-line summary, expanded-body text, and flattened result output from the
* frozen call slice. Input material comes from the call ARGUMENTS; output and
* error material from the settled result node. A supported terminal call gets
* its expanded body from `terminalCardModel` instead.
* one-line summary, expansion-time body input, and flattened result output
* from the frozen call slice. Input material comes from the call ARGUMENTS;
* output and error material from the settled result node. A supported terminal
* call gets its expanded body from `terminalCardModel` instead.
*/
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
@@ -92,8 +92,8 @@ export interface ToolRowModel {
* relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Expanded-body input text (pretty args); null = no input section. */
body: string | null
/** Original argument JSON retained for expansion-time body formatting. */
bodyRaw: string | null
/** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
output: string | null
/** First line of the result text on an error row; null for every other state. */
@@ -196,7 +196,13 @@ function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | unde
return picked === undefined ? undefined : firstLine(picked)
}
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
/**
* Format one argument payload when its generic input body becomes visible.
* @param variant - row presentation selected for the Tool name.
* @param argsRaw - original argument JSON or incomplete raw text.
* @returns display body, or null for empty input.
*/
export function formatToolBody(variant: ToolRowVariant, argsRaw: string): string | null {
if (argsRaw === '') return null
const parsed = parseArgs(argsRaw)
if (parsed === undefined) return argsRaw
@@ -238,12 +244,13 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
// would erase the collapsed error row's summary slot.
const output = done ? (resultText(block) || null) : null
const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
const bodyRaw = argsRaw === '' ? null : argsRaw
return {
variant,
titleKey: toolTitleKey ?? VARIANT_TITLE_KEYS[variant],
summary,
filePath: deriveFilePath(variant, argsRaw),
body: deriveBody(variant, argsRaw),
bodyRaw,
output,
errorSummary,
state,
@@ -51,7 +51,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
// Single-file tools never expose an args body — the path link is the only
// args interaction. A card is not an args body: a read/write/edit row is
// single-file AND carries a card, so the card expands under the path link.
body={singleFile ? null : model.body}
bodyRaw={singleFile ? null : model.bodyRaw}
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
@@ -186,7 +186,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr
icon={<IconQuestionOutline14 />}
title={t('ask.rowTitle')}
summary={summary}
body={transcript === null ? model.body : null}
bodyRaw={transcript === null ? model.bodyRaw : null}
output={transcript === null ? model.output : null}
askQuestion={transcript}
state={state}
@@ -1,4 +1,4 @@
import { useState, type KeyboardEvent } from 'react'
import { useMemo, useState, type KeyboardEvent } from 'react'
import type { Context } from '@deepseek-ai/cordis'
import clsx from 'clsx'
import {
@@ -13,7 +13,7 @@ import {
terminalCardModel,
terminalFailed,
} from '../models/terminal-card-model.ts'
import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
import { formatToolBody, toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
import { CONVERSATION_NS as NS } from '../../locale.ts'
import css from './bash-sample.module.css'
@@ -58,9 +58,15 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
// body; background acknowledgements and malformed calls remain collapsed.
const genericBody = terminal === null
&& (model.state === 'error' || isSettledPersistentShellCall(block))
&& (model.body !== null || model.output !== null)
&& (model.bodyRaw !== null || model.output !== null)
const expandable = terminal !== null || genericBody
const open = expanded && expandable
const body = useMemo(
() => open && genericBody && model.bodyRaw !== null
? formatToolBody(model.variant, model.bodyRaw)
: null,
[genericBody, model.bodyRaw, model.variant, open],
)
const failureLine = model.state === 'error' ? model.errorSummary : null
const toggleExpand = () => {
setExpanded(v => !v)
@@ -115,13 +121,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
)
: (
<div className={css.ioCard}>
{model.body !== null && (
{body !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>{t('row.input')}</span>
<span className={css.ioText}>{model.body}</span>
<span className={css.ioText}>{body}</span>
</div>
)}
{model.body !== null && model.output !== null && (
{body !== null && model.output !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{model.output !== null && (
@@ -23,7 +23,6 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect,
icon={<IconEditOutline16 size={14} />}
title={t(model.titleKey)}
summary={model.summary}
body={null}
output={model.output}
errorSummary={model.errorSummary}
diff={diff}
@@ -23,7 +23,6 @@ export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: Re
icon={<IconBrowseOutline16 size={14} />}
title={t(model.titleKey)}
summary={model.summary}
body={null}
output={model.output}
errorSummary={model.errorSummary}
read={read}
@@ -28,7 +28,6 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
? SEARCH_TITLE_KEYS.grep
: toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)}
summary={model.summary}
body={null}
// ToolRow ignores output when a structured card is present; otherwise it
// preserves the generic fallback for errors and legacy results.
output={model.output}
@@ -58,7 +58,7 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
title={t('todo.rowTitle')}
summary={summary.text}
summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null}
body={model.body}
bodyRaw={model.bodyRaw}
output={model.output}
errorSummary={model.errorSummary}
state={model.state}
@@ -29,7 +29,6 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
? WEB_TITLE_KEYS.web_search
: toolName === 'web_fetch' ? WEB_TITLE_KEYS.web_fetch : model.titleKey)}
summary={model.summary}
body={null}
output={model.output}
errorSummary={model.errorSummary}
web={web}
@@ -45,7 +45,7 @@ function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps {
describe('Tool presentation tails', () => {
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
const view = render(
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" state="stopped" />,
)
expect(view.queryByTestId('icon')).toBeNull()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
@@ -5,13 +5,16 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/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, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts'
import {
classifyTool, formatToolBody, resultText, toolRowModel,
} from '../src/client/tool/models/tool-call-model.ts'
import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
@@ -163,14 +166,17 @@ describe('tool-call-model', () => {
})
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()
expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: '{"a":1}' })).bodyRaw ?? ''))
.toBe('{\n "a": 1\n}')
expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: 'raw' })).bodyRaw ?? ''))
.toBe('raw')
expect(toolRowModel('bash', running({ argsRaw: '' })).bodyRaw).toBeNull()
expect(toolRowModel('bash', result({ call: null })).bodyRaw).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)
const model = toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' }))
expect(formatToolBody(model.variant, model.bodyRaw ?? ''))
.toBe('{\n "code": ""\n}')
})
@@ -228,7 +234,7 @@ 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,
summary: 'List files', bodyRaw: '{"a":1}', state: 'ok' as const,
}
it('renders leading icon, title and summary while collapsed', () => {
@@ -252,6 +258,18 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy()
})
it('formats the argument body only while expanding it', () => {
const stringify = vi.spyOn(JSON, 'stringify')
const view = render(<ToolRow {...rowProps} />)
expect(stringify).not.toHaveBeenCalled()
fireEvent.click(view.getByRole('button'))
expect(stringify).toHaveBeenCalledTimes(1)
fireEvent.click(view.getByRole('button'))
expect(stringify).toHaveBeenCalledTimes(1)
})
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()
@@ -264,7 +282,7 @@ describe('ToolRow', () => {
})
it('non-expandable rows render a passive leading slot and no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} />)
const view = render(<ToolRow {...rowProps} bodyRaw={null} />)
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByTestId('tool-icon')).not.toBeNull()
@@ -391,7 +409,7 @@ describe('ToolRow', () => {
expect(inputOnly.getByText('输入')).toBeTruthy()
expect(inputOnly.queryByText('输出')).toBeNull()
cleanup()
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
const outputOnly = render(<ToolRow {...rowProps} bodyRaw={null} output="only out" />)
fireEvent.click(outputOnly.getByRole('button'))
expect(outputOnly.queryByText('输入')).toBeNull()
expect(outputOnly.getByText('输出')).toBeTruthy()