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,