mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare 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,
|
||||
|
||||
@@ -4,102 +4,6 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
import type { V2Api } from "./api.js";
|
||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
|
||||
|
||||
describe("OpenCode v2 denial reasons", () => {
|
||||
test.each<{
|
||||
response: AgentPermissionResponse;
|
||||
decision: "reject" | "once" | "always";
|
||||
message?: string;
|
||||
}>([
|
||||
{
|
||||
response: { behavior: "deny", message: "Use the existing file instead." },
|
||||
decision: "reject",
|
||||
message: "Use the existing file instead.",
|
||||
},
|
||||
{ response: { behavior: "deny" }, decision: "reject" },
|
||||
{ response: { behavior: "allow" }, decision: "once" },
|
||||
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
|
||||
])("preserves a permission response: $response", async ({ response, decision, message }) => {
|
||||
const harness = new V2Harness();
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
await session.respondToPermission("approval", response);
|
||||
expect(replies).toEqual([
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision,
|
||||
...(message === undefined ? {} : { message }),
|
||||
},
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"explains cards superseded by a steer when clearing is %s",
|
||||
async (clearPendingPermissions) => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
const { turnId } = await session.startTurn("Run pwd");
|
||||
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
|
||||
await expect(
|
||||
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
|
||||
expect(replies).toEqual(
|
||||
clearPendingPermissions
|
||||
? [
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision: "reject",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("OpenCode v2 questions", () => {
|
||||
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
||||
|
||||
@@ -68,9 +68,6 @@ export class SessionPermissions {
|
||||
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
||||
requestID: requestId,
|
||||
decision: permissionReply(response),
|
||||
...(response.behavior === "deny" && response.message !== undefined
|
||||
? { message: response.message }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
this.resolvePending(requestId, response);
|
||||
|
||||
@@ -100,11 +100,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
),
|
||||
clearPermissions: async () => {
|
||||
for (const request of this.permissions.list())
|
||||
await this.permissions.respondToPermission(request.id, {
|
||||
behavior: "deny",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
});
|
||||
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
|
||||
},
|
||||
});
|
||||
this.usage = new SessionUsage({
|
||||
|
||||
@@ -23,10 +23,7 @@ export function createPushNotifications(options: {
|
||||
}): PushNotifications {
|
||||
const now = options.now ?? Date.now;
|
||||
const store = new PushTokenStore(options.logger, options.filePath, now, PUSH_TOKEN_LEASE_MS);
|
||||
const service = new PushService({
|
||||
logger: options.logger,
|
||||
revokeToken: (token) => store.revokeToken(token),
|
||||
});
|
||||
const service = new PushService(options.logger, (token) => store.revokeToken(token));
|
||||
const deliver =
|
||||
options.deliver ??
|
||||
((tokens: string[], payload: PushPayload) => service.sendPush(tokens, payload));
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PushService } from "./push-service.js";
|
||||
|
||||
describe("PushService", () => {
|
||||
test("requests high priority for every notification across batches", async () => {
|
||||
const requests: Record<string, unknown>[][] = [];
|
||||
const sendRequest: typeof fetch = async (_url, init) => {
|
||||
const messages = JSON.parse(String(init?.body)) as Record<string, unknown>[];
|
||||
requests.push(messages);
|
||||
return new Response(
|
||||
JSON.stringify({ data: messages.map(() => ({ status: "ok", id: "ticket" })) }),
|
||||
);
|
||||
};
|
||||
const service = new PushService({
|
||||
logger: pino({ level: "silent" }),
|
||||
revokeToken: () => undefined,
|
||||
sendRequest,
|
||||
});
|
||||
const tokens = Array.from({ length: 101 }, (_, index) => `ExponentPushToken[device-${index}]`);
|
||||
|
||||
await service.sendPush(tokens, {
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { agentId: "agent" },
|
||||
});
|
||||
|
||||
expect(requests.map((batch) => batch.length)).toEqual([100, 1]);
|
||||
expect(requests.flat()).toEqual(
|
||||
tokens.map((token) => ({
|
||||
to: token,
|
||||
priority: "high",
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { agentId: "agent" },
|
||||
sound: "default",
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ interface ExpoPushMessage {
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
sound?: "default";
|
||||
priority?: "default" | "normal" | "high";
|
||||
}
|
||||
|
||||
interface ExpoPushTicket {
|
||||
@@ -32,16 +31,10 @@ const MAX_BATCH_SIZE = 100;
|
||||
export class PushService {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly revokeToken: (token: string) => void;
|
||||
private readonly sendRequest: typeof fetch;
|
||||
|
||||
constructor(options: {
|
||||
logger: pino.Logger;
|
||||
revokeToken: (token: string) => void;
|
||||
sendRequest?: typeof fetch;
|
||||
}) {
|
||||
this.logger = options.logger.child({ component: "push-service" });
|
||||
this.revokeToken = options.revokeToken;
|
||||
this.sendRequest = options.sendRequest ?? fetch;
|
||||
constructor(logger: pino.Logger, revokeToken: (token: string) => void) {
|
||||
this.logger = logger.child({ component: "push-service" });
|
||||
this.revokeToken = revokeToken;
|
||||
}
|
||||
|
||||
async sendPush(tokens: string[], payload: PushPayload): Promise<void> {
|
||||
@@ -49,16 +42,12 @@ export class PushService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Expo defaults to "normal" priority on Android, which FCM holds back while
|
||||
// the device dozes, so notifications arrive minutes late. iOS already
|
||||
// defaults to "high".
|
||||
const messages: ExpoPushMessage[] = tokens.map((token) => ({
|
||||
to: token,
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
sound: "default",
|
||||
priority: "high",
|
||||
}));
|
||||
|
||||
// Batch tokens (max 100 per request per Expo limits)
|
||||
@@ -72,7 +61,7 @@ export class PushService {
|
||||
|
||||
private async sendBatch(messages: ExpoPushMessage[]): Promise<void> {
|
||||
try {
|
||||
const response = await this.sendRequest(EXPO_PUSH_URL, {
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
Reference in New Issue
Block a user