浏览代码

fix(client): preserve editor running diffs

imccyu 4 周之前
父节点
当前提交
d9a071340f

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml

@@ -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

+ 3 - 0
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md

@@ -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 |
 

+ 3 - 0
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md

@@ -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
packages/client/ui-tool/README.i18n.yaml

@@ -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
packages/client/ui-tool/README.md

@@ -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
packages/client/ui-tool/README.zh.md

@@ -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 负责。
 

+ 27 - 4
packages/client/ui-tool/src/client/tool/models/diff-card-model.ts

@@ -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') {

+ 41 - 0
packages/client/ui-tool/tests/diff-card.client.spec.tsx

@@ -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' }] },