fix(client): preserve editor running diffs

This commit is contained in:
imccyu
2026-08-24 10:24:18 +08:00
parent a99516c330
commit d9a071340f
8 changed files with 80 additions and 10 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-23-client-derived-tool-presentation.md
2026-08-23-client-derived-tool-presentation.md: 3643c003058f2c17c297606357e35fb43f2e5280
2026-08-23-client-derived-tool-presentation.zh.md: 31d3a9f082b937dd8d8002bfbc30ef85200ba367
2026-08-23-client-derived-tool-presentation.md: 957d2e6c1a79cb0b0a246066463e6ac960fb3d5b
2026-08-23-client-derived-tool-presentation.zh.md: 5fa1a350963ff74306520bad6a4fc89765c592a7
@@ -322,7 +322,10 @@ Standard and persistent providers sharing the same tool name are a special compa
|---|---|
| running `write` | intended added-only diff from `file_path` and `content` |
| running `edit` | intended replacement diff from `file_path`, `old_string`, and `new_string` |
| running `str_replace_editor create` | intended added-only diff from `path` and `file_text` |
| running `str_replace_editor str_replace` | intended replacement diff from `path`, `old_str`, and `new_str` |
| successful settled `write`/`edit` | applied contextual hunks from `meta.diffs` |
| settled `str_replace_editor` | Generic, because the tool defines no result presenter |
| write create or missing/malformed/empty applied metadata | current argument fallback |
| error, malformed arguments, edit with malformed metadata, or Code Dispatch child | Generic |
@@ -322,7 +322,10 @@ TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复
|---|---|
| running `write` | 从 `file_path``content` 生成 intended added-only diff |
| running `edit` | 从 `file_path``old_string``new_string` 生成 intended replacement diff |
| running `str_replace_editor create` | 从 `path``file_text` 生成 intended added-only diff |
| running `str_replace_editor str_replace` | 从 `path``old_str``new_str` 生成 intended replacement diff |
| settled `write``edit` success | 从 `meta.diffs` 生成 applied contextual hunks |
| settled `str_replace_editor` | Generic,因为该工具没有 result presenter |
| write create 或 applied metadata 缺失、畸形、为空 | 当前 args fallback |
| error、畸形 args、edit 的 metadata 畸形、Code Dispatch child | Generic |
+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: 16fd06332d24b265ac7d6b7b000870686262b9c5
README.zh.md: a21acab8ed6e7b52770a9ee6a63219c11b949c3d
README.md: 2db7d716dc80fbf40a953b217810fb8674e2e98f
README.zh.md: 79ed5befe751b329984c1320144921339fdf3d3f
+1 -1
View File
@@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () =>
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; the field is absent on a root Session call, so row and Details card models preserve the generic flattened form for descendants without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`.
This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`.
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), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes.
+1 -1
View File
@@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () =>
owner 载荷为 `ToolCallOwnerProps``callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。Code Dispatch block 保留其事件已有的 `parentCallId`root Session call 没有该字段,因此 row 与 Details card model 无需另一项 placement 标志即可让 descendant 保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~``filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。
本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall``presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create``str_replace`、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall``presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
各类卡片的上限与 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) Agent Note 负责。
@@ -1,4 +1,4 @@
/** Pure diff-card derivation from raw write/edit calls and result metadata. @module */
/** Pure diff-card derivation from raw file-mutation calls and result metadata. @module */
import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
import { parsedToolCall, validEscalationFields } from './raw-tool-call.ts'
@@ -47,11 +47,31 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null {
return out
}
type IntendedDiff = { tool: 'write' | 'edit'; diff: DiffHunk }
type IntendedDiff = { tool: 'write' | 'edit' | 'str_replace_editor'; diff: DiffHunk }
function intendedDiff(block: ToolCallBlock): IntendedDiff | null {
const parsed = parsedToolCall(block)
if (parsed === null) return null
if (parsed.name === 'str_replace_editor') {
const { command, path, file_text: fileText, old_str: oldText, new_str: newText } = parsed.args
if (typeof path !== 'string' || path.trim() === '') return null
if (command === 'create') {
if (fileText !== undefined && typeof fileText !== 'string') return null
return {
tool: 'str_replace_editor',
diff: { path, oldText: null, newText: fileText ?? '' },
}
}
if (command === 'str_replace') {
if (oldText !== undefined && typeof oldText !== 'string') return null
if (newText !== undefined && typeof newText !== 'string') return null
return {
tool: 'str_replace_editor',
diff: { path, oldText: oldText ?? null, newText: newText ?? '' },
}
}
return null
}
const { file_path: path } = parsed.args
if (typeof path !== 'string' || path.trim() === '') return null
if (!validEscalationFields(parsed.args)) return null
@@ -77,9 +97,11 @@ function appliedDiffs(meta: unknown): DiffHunk[] | 'empty' | null {
}
/**
* Derive intended running or applied settled diffs for a root write/edit call.
* Derive running diffs for root write/edit and `str_replace_editor`
* create/replace calls, plus applied settled diffs for root write/edit calls.
* A successful write with valid empty metadata uses its argument-derived
* whole-file diff, matching create and identical-overwrite presentation.
* whole-file diff, matching create and identical-overwrite presentation;
* `str_replace_editor` settles through Generic because it has no result view.
* @param block - running or settled Tool block.
* @returns the diff-card props, or null for the generic path.
*/
@@ -88,6 +110,7 @@ export function diffCardModel(block: ToolCallBlock): DiffCardModel | null {
const intended = intendedDiff(block)
if (intended === null) return null
if (!('kind' in block)) return { card: { diffs: [intended.diff] } }
if (intended.tool === 'str_replace_editor') return null
if (block.isError) return null
const applied = appliedDiffs(block.meta)
if (applied === null || applied === 'empty') {
@@ -63,6 +63,47 @@ describe('diffCardModel', () => {
})
})
it.each([
{
command: 'create',
args: { command: 'create', path: 'notes/new.txt', file_text: 'new file\n' },
diff: { path: 'notes/new.txt', oldText: null, newText: 'new file\n' },
},
{
command: 'str_replace',
args: { command: 'str_replace', path: 'notes/demo.txt', old_str: 'old', new_str: 'new' },
diff: { path: 'notes/demo.txt', oldText: 'old', newText: 'new' },
},
])('preserves the running str_replace_editor $command diff', ({ args, diff }) => {
expect(diffCardModel(running({
name: 'str_replace_editor',
argsRaw: JSON.stringify(args),
}))).toEqual({ card: { diffs: [diff] } })
})
it('preserves str_replace_editor defaults and its settled Generic result', () => {
const argsRaw = JSON.stringify({ command: 'str_replace', path: 'notes/demo.txt' })
expect(diffCardModel(running({ name: 'str_replace_editor', argsRaw }))).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: '' }] },
})
expect(diffCardModel(settled({
call: { name: 'str_replace_editor', argsRaw },
meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'old', newText: 'new' }] },
}))).toBeNull()
})
it('keeps unsupported or malformed str_replace_editor calls generic', () => {
const editor = (args: Record<string, unknown>) => running({
name: 'str_replace_editor', argsRaw: JSON.stringify(args),
})
expect(diffCardModel(editor({ command: 'view', path: 'notes/demo.txt' }))).toBeNull()
expect(diffCardModel(editor({ command: 'insert', path: 'notes/demo.txt', new_str: 'x' }))).toBeNull()
expect(diffCardModel(editor({ command: 'create', path: '', file_text: 'x' }))).toBeNull()
expect(diffCardModel(editor({ command: 'create', path: 'notes/demo.txt', file_text: 1 }))).toBeNull()
expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', old_str: 1 }))).toBeNull()
expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', new_str: 1 }))).toBeNull()
})
it('derives a settled card from result metadata, which replaces the intended diff', () => {
expect(diffCardModel(settled({
meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },