Files
paseo/packages/app/src/plugins/timeline/projection.test.ts
T
da8d58bfee fix(plugins): preserve agent message provenance in timeline transforms (#8492)
Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 05:04:23 +00:00

194 lines
5.9 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { TimelineItemTransform } from "./model";
import { projectPluginTimelineItems } from "./projection";
function thought(text: string, status: "loading" | "ready" = "loading"): StreamItem {
return {
kind: "thought",
id: "thought-1",
text,
status,
timestamp: new Date("2026-01-01T00:00:00.000Z"),
};
}
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 = {
kind: "tool_call",
id: "tool-1",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "claude",
callId: "call-1",
name: "Read",
status: "completed",
error: null,
detail,
},
},
};
const mutateDetail: TimelineItemTransform = ({ item }) => {
if (item.type === "tool_call" && item.detail.type === "read") {
item.detail.filePath = "/repo/mutated.ts";
}
return undefined;
};
expect(() => projectPluginTimelineItems([source], mutateDetail)).toThrow(TypeError);
expect(detail.filePath).toBe("/repo/original.ts");
});
it("projects a streaming reasoning row from its first delta", () => {
const transform: TimelineItemTransform = vi.fn(({ item, phase, sourceId }) => [
{
type: "plugin" as const,
id: `${sourceId}/0`,
pluginId: "inline-thinking",
kind: "reasoning",
version: 1,
data: { text: item.type === "reasoning" ? item.text : "", phase },
},
]);
expect(projectPluginTimelineItems([thought("First")], transform)).toMatchObject([
{
kind: "plugin",
id: "inline-thinking/thought-1/0",
data: { text: "First", phase: "streaming" },
},
]);
});
it("memoizes projection on the source row reference", () => {
const source = thought("Stable");
const transform: TimelineItemTransform = vi.fn(() => undefined);
projectPluginTimelineItems([source], transform);
projectPluginTimelineItems([source], transform);
expect(transform).toHaveBeenCalledOnce();
});
it("preserves whether a notification row came from an agent error or a notice", () => {
const transform: TimelineItemTransform = vi.fn(() => undefined);
const timestamp = new Date("2026-01-01T00:00:00.000Z");
projectPluginTimelineItems(
[
{
kind: "notification",
sourceType: "error",
id: "error-1",
level: "error",
message: "Agent failed",
timestamp,
},
{
kind: "notification",
sourceType: "notification",
id: "notice-1",
level: "error",
message: "Extension reported an error",
timestamp,
},
],
transform,
);
expect(transform).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ item: { type: "error", message: "Agent failed" } }),
);
expect(transform).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
item: {
type: "notification",
level: "error",
message: "Extension reported an error",
},
}),
);
});
it("reprojects when streaming produces a new source row", () => {
const transform: TimelineItemTransform = vi.fn(() => undefined);
projectPluginTimelineItems([thought("First")], transform);
projectPluginTimelineItems([thought("First second")], transform);
expect(transform).toHaveBeenCalledTimes(2);
});
it("filters and explodes source rows", () => {
const source = thought("Split", "ready");
const exploded = projectPluginTimelineItems([source], ({ sourceId }) => [
{
type: "plugin",
id: `${sourceId}/left`,
pluginId: "split",
kind: "part",
version: 1,
data: { side: "left" },
},
{
type: "plugin",
id: `${sourceId}/right`,
pluginId: "split",
kind: "part",
version: 1,
data: { side: "right" },
},
]);
const filtered = projectPluginTimelineItems([source], () => []);
expect(exploded.map((item) => item.id)).toEqual([
"split/thought-1/left",
"split/thought-1/right",
]);
expect(filtered).toEqual([]);
});
});