# B-260616-21 工具卡片重复渲染排查方案

> 排查性质（session-role-diagnose-only）：仅走查定位确切根因 + 产出两层修复方案，**未改任何代码**。
> 现象：对话记录里 `读取 .../api/ai.ts` 出现两次——`read_file` 两张卡：一「0 行 · 7.1KB · running」、一「183 行 · 7.1KB · completed」。
> 关联：[docs/todo.md](../todo.md) B-260616-21；前端流转 [useAiEvents.ts](../../src/composables/ai/useAiEvents.ts)；后端 emit [audit.rs](../../src-tauri/src/commands/ai/audit.rs)。

---

## 1. 现象与候选根因回顾（已在 todo 登记）

登记在 [docs/todo.md](../todo.md) `### 🔧 2026-06-16 aichat 工具卡片重复渲染排查` 段，两候选：

- **候选 A（最贴合现象）**：同一 `tool_call_id` 被**重复 emit Started** → 前端 push 两张卡 → `AiToolCallCompleted` 只更首张（`findToolCall` 命中首个）→ 次张卡永驻 `running`（`parsed?.lines||0` 兜底显 0 行）。与「0 行 + 183 行」现象完全吻合。
- **候选 B**：agent loop 多轮真读两次（不同 id）→ 应两卡皆 183 行 completed，与现象不符 → **排除为主因**。

本文深化走查定位候选 A 的确切重复 emit 环节，并给出两层修复方案。

---

## 2. 链路走查（file:line 独立 grep/Read 核验）

### 2.1 前端流转（已核验）

| 位置 | 行为 | 关键事实 |
|---|---|---|
| `useAiEvents.ts:195-209` | `AiToolCallStarted` 分支：构造 `info`（`id/name/args/status:'running'`）→ `lastMsg.toolCalls.push(info)` | **直接 push，无 id 幂等守卫**。对比同文件 `startToolSlowTimer:58` 有 `if (_toolTimers.has(callId)) return` 守卫——Started 漏了同款判重。 |
| `useAiEvents.ts:212-223` | `AiToolCallCompleted` 分支：`findToolCall(event.id)` → 命中则 `tc.status='completed'; tc.result=event.result` | 只 update 一张卡。 |
| `aiShared.ts:41-49` | `findToolCall`：尾部反向扫，**命中首个** `tc.id===id` 返回 | 同 id 被 push 两次时，Completed 只更第一张（reverse 先撞尾部 = 后 push 的那张？见 §3.1 注）。 |
| `useAiEvents.ts:57-58` | `startToolSlowTimer`：`if (_toolTimers.has(callId)) return` 幂等守卫 | 后端若重 emit 同 id Started，第二张卡仍会 push（无守卫），但慢执行计时器不重建——**计时器侧有守卫，卡片侧没有**，前端守卫不一致。 |

### 2.2 后端 emit 点（已核验）

| 位置 | emit | 触发条件 |
|---|---|---|
| `audit.rs:541-546` | `AiToolCallStarted { id: draft.id.clone(), ... }` | `process_tool_calls` 内 `drafts` 迭代，**每个 draft 一次** |
| `audit.rs:532-533` | `tc_list: Vec<_> = tool_calls_acc.into_iter().collect()` + `sort_unstable_by_key` | `tc_list` 来源是 `tool_calls_acc: HashMap<u32, ToolCallDraft>`（**键是流式 index u32，非 id 字符串**） |
| `audit.rs:573-577` | F-05 高危去重命中 → emit `AiToolCallCompleted`（`continue` 跳过审批） | **仅 Completed，不重 emit Started**——安全 |
| `commands.rs:304-308` | 审批通过 → 执行后 emit `AiToolCallCompleted` | **仅 Completed，不重 emit Started**——安全 |

### 2.3 id 来源（已核验）

| 位置 | 行为 |
|---|---|
| `stream_recv.rs:223-229` | LLM 流式 chunk 的 `tool_calls` delta → `tool_calls_acc.entry(tc_delta.index).or_default()` → `if let Some(id) = &tc_delta.id { draft.id = id.clone(); }` |
| `mod.rs:286-291` | `ToolCallDraft { id: String, name, args }` derive Default（`id` 默认空串） |
| `crates/df-ai/src/anthropic_compat.rs:186-191` | Anthropic 流：`id` 直接取 LLM 返回的 `tool_use.id`（`Option`，可能 None → draft.id 保持空串） |
| `crates/df-ai/src/openai_compat.rs:177-182` | OpenAI 流：`id: tc.id`（LLM 返回值，可为 None） |

**关键结论**：`draft.id` **完全由 LLM stream 提供**，`process_tool_calls` / `agentic.rs` **不重新生成也不克隆复用** id。`process_tool_calls` 对 `tc_list` 每 draft emit 一次 Started，**单次调用内不重复**。

### 2.4 process_tool_calls 调用频次（已核验）

| 位置 | 调用 |
|---|---|
| `agentic.rs:474-477` | `let pending_count = { let mut session = ...; process_tool_calls(&mut session, tool_calls_acc, ...) }` |

- `tool_calls_acc` **by value**（move）传入，用完即消费，**每轮 iteration 调一次**，不重入同轮。
- `run_agentic_loop` 主循环（`agentic.rs:176 for iteration in start_iteration..max_iterations`）每轮一次。

---

## 3. 确切根因：候选 A 成立，重复 emit 源在 LLM 同 id 复用

### 3.1 重复 emit 的两可能环节

**所有 emit Started 的代码路径只剩 `audit.rs:541` 一处**（grep `AiToolCallStarted` 全仓仅此一处 + mod.rs 枚举定义 + 注释）。要在该处对同一 id emit 两次，必须满足：**`tc_list`（= `tool_calls_acc.into_iter()`）含两条 `ToolCallDraft`，其 `id` 字段值相同**。`HashMap<u32, _>` 键是流式 index（u32），**两条 draft 可并存**——只要它们 index 不同但 id 字符串相同。

两条可能的产生路径：

#### 路径 A1：单次流式内 LLM 在不同 index 上复用同一 tool_use.id（最贴合）

- LLM（尤其 GLM 经 anthropic_compat 端点，已知 id 生成不稳，见 todo `### 🔴 anthropic_compat 多轮工具调用` 段 B-260614-AC1/AC2）在**同一次 stream**内，两个 `tool_use` block 复用同一 id 字符串（或一次正常 + 一次重传 delta 残留）。
- `stream_recv.rs:225` `entry(tc_delta.index).or_default()`——按 index 分桶，两条不同 index 的 draft 各自累积，若 LLM 给两条不同 index 的 tool_use 都写了同一 id → `tool_calls_acc` 含两条 id 相同的 draft。
- → `process_tool_calls` 对两条各 emit 一次 Started（同 id）→ 前端 push 两张卡。

#### 路径 A2：跨 iteration LLM 重发同一 tool_use.id

- 主 loop（`agentic.rs:176`）跨 iteration 时，LLM 在不同轮次对相同语义的工具调用复用同一 id 字符串（provider 侧缓存/重传）。
- 跨 iteration 的 Started 会落到**同一条 assistant 消息**的 `toolCalls`（`useAiEvents.ts:202` 取 `state.messages[length-1]`，若 `AiAgentRound` 未先新建 assistant 消息则累积同消息；即使新建消息也跨消息污染）。

> **判断**：A1（单次流式内同 id 不同 index）比 A2 更贴合，因 A2 跨轮通常伴 `AiAgentRound` 新建 assistant 消息（`useAiEvents.ts:154-171`），重复卡会分属不同消息气泡，用户报「同一对话记录里出现两次」更可能 A1（同一消息内两张）。但两者**前端症状一致**（同 id 双卡、Completed 只更其一），**前端守卫可一并治标**。

### 3.2 前端放大缺陷（为何重复 emit 后只剩「0 行 running」）

1. `useAiEvents.ts:204-205` `lastMsg.toolCalls.push(info)` 无 `findToolCall(event.id)` 守卫 → 后端真 emit 两次同 id Started，前端真 push 两张。
2. `aiShared.ts:42` `findToolCall` **反向扫命中首个**——Completed 到达时，反向遍历先撞**后 push 的那张**（尾部）。故 Completed update 的是**第二张**（后 push），**第一张**（先 push）永驻 `running`，显示 `0 行 · 7.1KB`（`ToolCard.vue:72` `parsed?.lines||0` running 态无 result 兜底 0）。
   - **更正 §2.1 表「Completed 只更第一张」表述**：实测 findToolCall 反向命中尾部，故被更的是后 push 的那张，残留 running 的是先 push 的。现象「一 0 行 running、一 183 行 completed」与「先 push 残留 running / 后 push 被 Completed 更」一致。
3. 即使后端不重复 emit，**理论上 findToolCall 反向命中 + Started 无守卫**的组合本身就在「同 id 两次 Started」时产出「一卡永久 running」坏形态——这是前端层独立缺陷。

### 3.3 与 F-260616-05 的区别（已在 todo 标注）

- F-05（batch53）：**不同 tool_call_id** 的同 tool_name+args 重复调用去重（High risk 进审批门前 `find_cached_high_risk_result` 反向扫 messages）。治的是「LLM 真调两次同命令」。
- B-260616-21：**同一 tool_call_id** 被重复 emit Started。治的是「LLM 给两个 tool_use block 复用同一 id 字符串」。
- 两者维度不同，F-05 的去重逻辑（按 args 匹配、跳过审批）**不能拦截**本条（本条 id 相同、index 不同，F-05 走的是 draft.id 维度的审批插桩，不防同 id 多 emit）。

---

## 4. 修复方案（两层）

### 方案 ① 前端幂等守卫（治标·确定性低风险·推荐立即落地）

**改动**：`src/composables/ai/useAiEvents.ts:195-209` `AiToolCallStarted` 分支，push 前加 `findToolCall(event.id)` 守卫：

```ts
case 'AiToolCallStarted': {
  // B-260616-21: id 幂等守卫——后端若对同一 tool_call_id 重复 emit Started
  // （LLM 在不同 index 复用同 id / 跨轮同 id），仅保留首张卡，避免「同 id 双卡、
  // Completed 只更其一、另一张永驻 running 显 0 行」。对齐 startToolSlowTimer:58 守卫风格。
  if (findToolCall(event.id)) {
    // 已存在同 id 卡：补挂慢执行计时器（防首张 timer 被中途清后此 emit 不重建），
    // 但不重复 push 卡片。startToolSlowTimer 自身有 _toolTimers.has 守卫，重复调安全。
    startToolSlowTimer(event.id, event.name)
    break
  }
  const info: AiToolCallInfo = {
    id: event.id,
    name: event.name,
    args: event.args,
    status: 'running',
  }
  const lastMsg = state.messages[state.messages.length - 1]
  if (lastMsg && lastMsg.role === 'assistant') {
    lastMsg.toolCalls = lastMsg.toolCalls || []
    lastMsg.toolCalls.push(info)
  }
  startToolSlowTimer(event.id, event.name)
  break
}
```

**生效语义**：
- 后端重复 emit 同 id Started → 第二次起命中 `findToolCall` → 不 push 新卡 → Completed（无论命中首张或尾部，因只有一张卡）正确 update → 无残留 running 卡。
- 同 id 跨 assistant 消息（A2 场景，不同消息的 toolCalls）也守得住——`findToolCall` 扫**全部消息**（`aiShared.ts:42` 从尾反向遍历 `state.messages`），跨消息同 id 也会命中。
- 风险：若 LLM 真用同 id 调两次**不同语义**的工具（id 冲突但语义不同，极罕见），第二次工具的卡片会被吞 → 用户看不到第二次调用。但**这种 id 冲突本身就是 LLM 协议违规**（同 id 必同语义，tool_result 按 id 配对），后端 `ChatMessage::tool_result(&draft.id, ...)` 也只按 id 配对一个结果——故前端吞掉第二张是正确行为，与后端语义一致。

**改动范围**：单文件单 case，~6 行净增，`findToolCall` 已 import（`useAiEvents.ts:20`）。对齐同文件 `startToolSlowTimer:58` 守卫模式，无新机制。`vue-tsc` 应 0 err（纯前端逻辑，无类型变动）。

**与 startToolSlowTimer 守卫的一致性**：本修复使 Started 的卡片侧与计时器侧都具备 id 幂等守卫，消除「计时器有守卫、卡片没有」的前端守卫不一致（§2.1）。

### 方案 ② 后端治本（定位重复 emit 源·需进一步取证）

前端守卫是兜底，根因在后端真发了同 id 两次 Started。治本需先**确认是 A1 还是 A2**：

**取证步骤**（不改代码，加临时日志或读现有日志）：
1. 在 `audit.rs:541` emit Started 前加 `tracing::info!`（临时）：打印 `draft.id` + `draft.name` + `tc_list.len()` + 该 id 在 tc_list 中出现次数。复现后看是否单次 `process_tool_calls` 调用内同 id 出现 ≥2 次（A1），还是跨调用出现（A2）。
2. 在 `stream_recv.rs:226` `draft.id = id.clone()` 处加日志：打印 `tc_delta.index` + `id`，看 LLM 流式是否给不同 index 同 id。

**取证后治本方向（A1 vs A2 分支）**：

- **若 A1（单次流式内同 id 不同 index）**：根因在 LLM provider 层（anthropic_compat / openai_compat）id 生成不稳。治本点在 `stream_recv.rs:225` 或 provider 解析层——
  - 选项 a：`process_tool_calls` 内对 `tc_list` 按 `draft.id` 去重（同 id 保留首个 index，丢弃后续）。
  - 选项 b：provider 层 id 缺失/冲突时生成占位 id（对齐已落地的 B-260614-AC1/AC2 占位 id 机制 `tool_missing_{idx}` / `tool_use_{idx}`）。
  - 倾向 b（provider 层根治，process 层去重是兜底）。

- **若 A2（跨 iteration 同 id）**：根因在 LLM 跨轮复用 id。治本点在 `process_tool_calls` 入口校验 `draft.id` 是否已在 `session.messages` 历史 tool_result 中存在（跨轮去重）——但这与 F-260616-05 去重机制部分重叠，需谨慎区分（F-05 按 args 去重 High risk 跳审批；本条按 id 去重跨轮 Low/Med/High 一致）。倾向：跨轮同 id 视为 LLM 协议违规，前端守卫兜底即可，后端不额外处理（避免与 F-05 去重逻辑耦合）。

**治本建议**：先落地方案 ① 前端守卫（立即消除用户可感坏形态），取证步骤并行进行，据 A1/A2 结论再决定后端治本是否必要（若 A1 频发，provider 层补占位 id 生成；若 A2 罕见，前端守卫足矣）。

---

## 5. 风险评估

| 项 | 方案 ① 前端守卫 | 方案 ② 后端治本 |
|---|---|---|
| 行为变更 | 同 id 第二次 Started 不再 push 卡（吞掉重复） | 视 A1/A2 分支而定 |
| 兼容性 | LLM 协议合规（同 id 必同语义）下零影响；协议违规下吞掉违规第二张，与后端 tool_result 按 id 配对语义一致 | 需配套测试，避免误伤合法重发 |
| 回归面 | 单 case，无类型/数据结构变动 | provider 层 / process 层，触及多文件 |
| 建议优先级 | **P2 立即落地**（治标兜底，消除用户可感坏形态） | **取证后再定**（可能不必要，若 A1 罕见） |

---

## 6. 待办（回写 docs/todo.md）

- [ ] B-260616-21 方案 ① 前端 `useAiEvents.ts:195-209` Started 分支加 `findToolCall(event.id)` 幂等守卫（~6 行，对齐 `startToolSlowTimer:58` 守卫，确定性低风险，立即落地治标）。
- [ ] B-260616-21 方案 ② 取证：临时日志确认 A1（单次流式内同 id 不同 index）/ A2（跨 iteration 同 id），据结论决定后端治本（provider 占位 id 生成 / process 层去重 / 不处理）。**依赖** ① 落地后再做（① 兜底后现象消失，取证需临时去 ① 守卫复现）。

---

## 7. 核验证据汇总（防上下文污染·独立 grep/Read）

| 结论 | 证据 |
|---|---|
| AiToolCallStarted 全仓唯一 emit 点 | `grep AiToolCallStarted` → `audit.rs:541`（emit）+ `mod.rs:95`（枚举）+ `mod.rs:19`（注释） |
| F-05 高危去重不重 emit Started | `audit.rs:573-577` 仅 emit `AiToolCallCompleted` + `continue` |
| 审批执行不重 emit Started | `commands.rs:304-308` 仅 emit `AiToolCallCompleted` + `AiApprovalResult` |
| draft.id 完全来自 LLM stream | `stream_recv.rs:226` `draft.id = id.clone()`（id 取自 `tc_delta.id`，LLM 提供） |
| process_tool_calls 不重生成/克隆 id | `audit.rs:538-549` 仅 `draft.id.clone()` 透传给 emit，无新 id 生成 |
| tc_list 键是 index 非 id | `audit.rs:532` `tool_calls_acc.into_iter()`，`tool_calls_acc: HashMap<u32, ToolCallDraft>`（u32 = 流式 index） |
| 前端 Started 无守卫 | `useAiEvents.ts:202-206` 直接 push，无 `findToolCall` 判重 |
| 前端 findToolCall 命中首个（反向） | `aiShared.ts:42-49` 从 `state.messages.length-1` 反向遍历，命中即 return |
| startToolSlowTimer 有守卫（对比） | `useAiEvents.ts:58` `if (_toolTimers.has(callId)) return` |
| ToolCard running 兜底 0 行 | `ToolCard.vue:72` `parsed?.lines || 0`（running 态无 result） |
| ToolCallDraft derive Default | `mod.rs:286` `#[derive(Debug, Clone, Default)]`，id 默认空串 |
