From 57346d438c68cc54e0f1686ce86ae1c31861e998 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 21:41:44 +0800 Subject: [PATCH] fix(web): replayed tool call after resume reuses its card (duplicate React key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A suspended agent.propose_options / approval call is replayed on resume with the same toolCallId; foldEvents pushed a second item with the same key, which also inflated the tool call meter. Also use 「」 in the report footnote. Co-Authored-By: Claude Opus 5.5 --- .../components/agent/agent-conversation.tsx | 2 +- .../components/agent/agent-helpers.test.ts | 32 ++++++++++++++++++- .../app/components/agent/use-agent-stream.ts | 8 +++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 232b20dc..f117740f 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -789,7 +789,7 @@ export function FinalReport({ ) : null}

取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)} - )。"已提交"指已进入下载器,整理成功才算入库。 + )。「已提交」指已进入下载器,整理成功才算入库。

) : ( diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts index 597d6200..f1858ee5 100644 --- a/apps/web/app/components/agent/agent-helpers.test.ts +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -1,7 +1,8 @@ +import type { AgentRunEvent } from "@umt/contracts"; import { describe, expect, it } from "vitest"; import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx"; import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts"; -import type { StreamItem, ToolCallView } from "./use-agent-stream.ts"; +import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts"; const call = (p: Partial): ToolCallView => ({ toolCallId: "t1", @@ -81,3 +82,32 @@ describe("侧栏上下文", () => { expect(splitContextGoal(goal)).toEqual({ context: expect.stringContaining("taskId="), text: "排查这个任务" }); }); }); + +describe("事件折叠", () => { + const ev = (seq: number, body: Record) => + ({ runId: "r1", seq, at: "2026-09-28T00:00:00.000Z", ...body }) as unknown as AgentRunEvent; + it("挂起后恢复重放同一 toolCallId:只保留一张工具卡,状态以重放为准", () => { + const start = { + type: "tool_call_start", + toolCallId: "tc1", + capabilityId: "agent.propose_options", + inputSummary: "{}", + }; + const items = foldEvents([ + ev(1, { ...start, recovery: false }), + ev(2, { ...start, recovery: true }), + ev(3, { + type: "tool_call_end", + toolCallId: "tc1", + capabilityId: "agent.propose_options", + outputSummary: "ok", + isError: false, + recovery: true, + }), + ]); + const tools = items.filter((i) => i.kind === "tool"); + expect(tools).toHaveLength(1); + expect(new Set(items.map((i) => i.key)).size).toBe(items.length); + expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true }); + }); +}); diff --git a/apps/web/app/components/agent/use-agent-stream.ts b/apps/web/app/components/agent/use-agent-stream.ts index afb05e5f..42d48180 100644 --- a/apps/web/app/components/agent/use-agent-stream.ts +++ b/apps/web/app/components/agent/use-agent-stream.ts @@ -106,6 +106,14 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] { else items.push({ kind: "thinking", key: `th${e.seq}`, text: e.delta, at: e.at }); break; case "tool_call_start": { + // 挂起的调用(方案选择 / 审批 / 等子会话)恢复时按同一 toolCallId 重放:沿用原卡片,不重复出卡 + const prev = tools.get(e.toolCallId); + if (prev) { + Object.assign(prev, { status: "running", outputSummary: null, reason: null, endedAt: null }); + prev.recovery = e.recovery; + if (e.inputSummary) prev.inputSummary = e.inputSummary; + break; + } const call: ToolCallView = { toolCallId: e.toolCallId, capabilityId: e.capabilityId,