From e513a9130aab178ccb95cf072ac2daa6f2bb10df Mon Sep 17 00:00:00 2001 From: luke Date: Tue, 20 Oct 2026 18:23:48 +0800 Subject: [PATCH] fix(plugins): preserve agent message provenance in timeline transforms Co-authored-by: RoundRedDot --- .../src/plugins/timeline/projection.test.ts | 39 +++++++++++++++++++ .../app/src/plugins/timeline/projection.ts | 3 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/app/src/plugins/timeline/projection.test.ts b/packages/app/src/plugins/timeline/projection.test.ts index 2dc95ed339..fc6e0bfa19 100644 --- a/packages/app/src/plugins/timeline/projection.test.ts +++ b/packages/app/src/plugins/timeline/projection.test.ts @@ -14,6 +14,45 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI } describe("plugin timeline projection", () => { + it.each(["message", "finished", "errored", "permission-required", "closed"] as const)( + "preserves immutable agent-message provenance for %s cards", + (event) => { + const agentMessage = { + event, + sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" }, + text: "The original message, including **Markdown**.", + }; + const source: StreamItem = { + kind: "tool_call", + id: "message-1", + timestamp: new Date("2026-01-01T00:00:00.000Z"), + payload: { + source: "agent", + data: { + provider: "codex", + callId: "delivery-1", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: agentMessage.text, icon: "bot" }, + agentMessage, + }, + }, + }; + const inspect: TimelineItemTransform = ({ item }) => { + expect(item.type).toBe("tool_call"); + if (item.type !== "tool_call") throw new Error("Expected a tool call"); + expect(item.agentMessage).toEqual(agentMessage); + expect(item.agentMessage).not.toBe(agentMessage); + expect(Object.isFrozen(item.agentMessage)).toBe(true); + expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true); + return undefined; + }; + expect(projectPluginTimelineItems([source], inspect)).toEqual([source]); + expect(Object.isFrozen(agentMessage.sender)).toBe(false); + }, + ); + it("does not expose mutable tool detail from stream state", () => { const detail = { type: "read" as const, filePath: "/repo/original.ts" }; const source: StreamItem = { diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 0750bdb66a..71baf86174 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -22,13 +22,14 @@ function cloneAndFreeze(value: T): T { } function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem { - const { callId, name, status, error, detail, metadata } = data; + const { callId, name, status, error, detail, metadata, agentMessage } = data; const base = { type: "tool_call" as const, callId, name, detail, ...(metadata ? { metadata } : {}), + ...(agentMessage ? { agentMessage } : {}), }; switch (status) { case "running":