mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d203d780e | ||
|
|
b6511ea5f9 | ||
|
|
d2c372d78b |
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user