Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 1d203d780e test(plugins): verify repeated timeline IDs in a real browser
Exercise the installed plugin through the real daemon and chat viewport. Read the oldest virtualized replacement before and after reload, using actual browser row heights instead of extending the legacy JSDOM harness.
2026-11-01 22:50:24 +00:00
b6511ea5f9 fix(plugins): keep transformed replies distinct in chat history
Replacement IDs are local to their source message. Reusing a stable output
ID across replies aliased the row and virtualizer caches, reproducing a
packaged desktop React #185 crash. Scope row identity at projection and
retain the original prompt identity for navigation.

Build on the contributor regression, exercising transformed source rows
instead of assigning occurrence keys in the virtualizer.

Co-authored-by: Kevin Le <anhle12892@gmail.com>
Co-authored-by: ykurilov <110710320+ykurilov@users.noreply.github.com>
2026-11-01 22:28:17 +00:00
Kevin Le d2c372d78b fix(app): keep chat history from crashing when two rows share an id
The chat virtualizer caches measured row sizes by key, and the key was the
row id. When two history rows had the same id, both rows wrote the same
cache entry. Each measurement pass set the size to the other row's height,
so the layout effect re-rendered without end and React aborted with
"Maximum update depth exceeded" (minified error #185).

Give each virtualized row a unique key. A repeated id gets a numeric
suffix, and the first occurrence keeps its plain id so the reading anchor
still matches it.
2026-11-01 22:27:28 +00:00
5 changed files with 165 additions and 6 deletions
@@ -9,6 +9,9 @@ import {
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
withRepeatedOutputTimeline,
readOldestPluginReplacement,
expectOldestPluginReplacementReadable,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -54,3 +57,15 @@ test("inline-thinking example renders a streamed thought through host UI", async
}, info) => {
await viewInlineThinkingExample(page, info);
});
test("keeps older plugin replacements readable when source messages reuse local output IDs", async ({
page,
}, info) => {
await withRepeatedOutputTimeline(page, info, async () => {
await readOldestPluginReplacement(page);
await expectOldestPluginReplacementReadable(page);
await page.reload({ waitUntil: "domcontentloaded" });
await readOldestPluginReplacement(page);
await expectOldestPluginReplacementReadable(page);
});
});
@@ -7,6 +7,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { expectReconnectingToastVisible } from "./workspace-ui";
import { userScrollsTimelineToHistoryStart } from "./timeline-pagination";
const PLUGIN_ID = "timeline-transform-e2e";
@@ -367,3 +368,81 @@ export async function viewInlineThinkingExample(page: Page, info: TestInfo): Pro
await example.cleanup();
}
}
/** Reuse plugin-local IDs across distinct source messages in a real virtualized chat. */
export async function withRepeatedOutputTimeline(
page: Page,
info: TestInfo,
run: () => Promise<void>,
): Promise<void> {
info.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-local-ids-",
title: "Repeated plugin output IDs",
});
try {
for (const label of ["First", "Second", "Third"]) {
await agent.client.sendAgentMessage(
agent.agentId,
`${label}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await withDirectoryPlugin({
pluginId: "timeline-local-ids",
source: `import React from "react";
import { Text, View } from "react-native";
import { z } from "zod";
function Row({ item }) {
return <View style={{ height: item.data.tall ? 120 : 24 }}>
<Text>{item.data.label}</Text>
</View>;
}
export default function contribute(client) {
client.addTimelineTransformer({
id: "local-ids", query: { itemType: "user_message" },
transform({ item }) {
const label = item.text.split(":")[0];
return { items: Array.from({ length: 60 }, (_, index) => ({
type: "plugin", id: "part-" + index, kind: "reply", version: 1,
data: { label: label + " replacement " + index, tall: label === "Second" },
})) };
},
});
client.addTimelineRenderer({
kind: "reply", version: 1,
schema: z.object({ label: z.string(), tall: z.boolean() }), Component: Row,
});
return () => {};
}`,
run: async () => {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await run();
},
});
} finally {
await agent.cleanup();
}
}
export async function readOldestPluginReplacement(page: Page): Promise<void> {
await expect(page.getByText("Third replacement 59", { exact: true }).first()).toBeAttached();
await expect
.poll(async () => {
await userScrollsTimelineToHistoryStart(page);
return page.getByText("First replacement 0", { exact: true }).isVisible();
})
.toBe(true);
}
export async function expectOldestPluginReplacementReadable(page: Page): Promise<void> {
await expect(
page
.getByTestId("agent-chat-scroll")
.locator("[data-index]")
.getByText("First replacement 0", { exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
}
@@ -200,7 +200,9 @@ describe("stream presentation through installed plugins", () => {
const source = rows(harness.source());
expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
expect(rendered).toMatchObject([
{ kind: "plugin", sourceId: "prompt-1", pluginItemId: "prompt-1/0" },
]);
// The chat outline jumps to the source id; the row it lands on is the plugin's.
expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
});
@@ -14,6 +14,65 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
}
describe("plugin timeline projection", () => {
it("keeps repeated plugin output IDs distinct across source messages", () => {
const transform: TimelineItemTransform = ({ item }) => [
{
type: "plugin",
id: "part-0",
pluginId: "split",
kind: "part",
version: 1,
data: { text: item.type === "assistant_message" ? item.text : "" },
},
];
const first: StreamItem = {
kind: "assistant_message",
id: "message-1",
text: "Short",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
};
const second: StreamItem = { ...first, id: "message-2", text: "A much longer reply" };
const rows = projectPluginTimelineItems([first, second], transform);
expect(new Set(rows.map((row) => row.id)).size).toBe(2);
expect(rows).toMatchObject([
{ sourceId: "message-1", pluginItemId: "part-0", data: { text: "Short" } },
{ sourceId: "message-2", pluginItemId: "part-0", data: { text: "A much longer reply" } },
]);
const updated = projectPluginTimelineItems(
[{ ...second, text: "Updated reply" }],
transform,
"streaming",
);
expect(updated[0].id).toBe(rows[1].id);
const completed = projectPluginTimelineItems([{ ...second, text: "Final reply" }], transform);
expect(completed[0].id).toBe(rows[1].id);
const prepended = projectPluginTimelineItems(
[{ ...first, id: "older-message" }, first, second],
transform,
);
expect(prepended.slice(1).map((row) => row.id)).toEqual(rows.map((row) => row.id));
});
it("keeps source and output ID separators unambiguous", () => {
const rows = projectPluginTimelineItems(
[
{ ...thought("First"), id: "message/a" },
{ ...thought("Second"), id: "message" },
],
({ sourceId }) => [
{
type: "plugin",
id: sourceId === "message/a" ? "b" : "a/b",
pluginId: "split",
kind: "part",
version: 1,
data: null,
},
],
);
expect(rows[0].id).not.toBe(rows[1].id);
});
it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = {
@@ -58,7 +117,8 @@ describe("plugin timeline projection", () => {
expect(projectPluginTimelineItems([thought("First")], transform)).toMatchObject([
{
kind: "plugin",
id: "inline-thinking/thought-1/0",
sourceId: "thought-1",
pluginItemId: "thought-1/0",
data: { text: "First", phase: "streaming" },
},
]);
@@ -145,10 +205,11 @@ describe("plugin timeline projection", () => {
]);
const filtered = projectPluginTimelineItems([source], () => []);
expect(exploded.map((item) => item.id)).toEqual([
"split/thought-1/left",
"split/thought-1/right",
expect(exploded).toMatchObject([
{ sourceId: "thought-1", pluginItemId: "thought-1/left" },
{ sourceId: "thought-1", pluginItemId: "thought-1/right" },
]);
expect(new Set(exploded.map((item) => item.id)).size).toBe(2);
expect(filtered).toEqual([]);
});
});
@@ -103,7 +103,9 @@ function transformSourceItem(
return transformed.map((pluginItem) => {
const projected: PluginTimelineStreamItem = {
kind: "plugin",
id: `${pluginItem.pluginId}/${pluginItem.id}`,
// Output IDs are local to a source item. Encode each part so separators in
// source or output IDs cannot alias another replacement.
id: JSON.stringify([pluginItem.pluginId, item.id, pluginItem.id]),
sourceId: item.id,
timestamp: item.timestamp,
pluginId: pluginItem.pluginId,