Compare commits

..
Author SHA1 Message Date
Mohamed Boudra aae97b2bdc fix(app): regenerate iOS Markdown inline code props
(cherry picked from commit b3ca2327a8)
2026-10-27 16:45:05 +01:00
31 changed files with 31 additions and 1236 deletions
-9
View File
@@ -382,15 +382,6 @@ grace. During daemon startup, plugin sessions may connect while application WebS
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
passing the bundle string still costs memory and time once per client lifetime.
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
@@ -1,5 +1,4 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
withTimelinePlugin,
viewInlineThinkingExample,
@@ -7,8 +6,6 @@ import {
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -16,24 +13,13 @@ for (const width of [1100, 390]) {
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page);
});
});
@@ -1,7 +1,5 @@
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -216,66 +214,3 @@ test.describe("provider settings overlay stack", () => {
}
});
});
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width, height: 900 });
await page.getByRole("button", { name: /Select model/ }).click();
if (width < 600) {
await page.getByTestId("agent-controls-model").click();
} else {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await page.getByTestId("model-provider-picker-timeout").click();
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
}
async function expectErrorHasSideGutters(page: Page) {
const error = page.getByText(/Timed out refreshing Pi timeout/);
const bounds = await error.evaluate((element) => {
const text = element.getBoundingClientRect();
const state = element.parentElement!.getBoundingClientRect();
return { left: text.left - state.left, right: state.right - text.right };
});
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
catalogRefreshTimeoutMs: 2000,
providers: {
"picker-timeout": {
extends: "pi",
label: "Pi timeout",
enabled: true,
command: [
process.execPath,
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
"--stall-model-discovery",
],
},
},
},
},
});
test.describe("model discovery timeout", () => {
for (const width of [1200, 390]) {
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
page,
withWorkspace,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 900 });
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
await openTimedOutProvider(page, workspace.workspaceId, width);
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
await expectErrorHasSideGutters(page);
});
}
});
+1 -14
View File
@@ -475,20 +475,7 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await group.getByRole("button").first().click();
// Expand thinking is off: the group opens its rows, but the thought row stays closed
// until the reader opens it.
const thought = group
.getByTestId("tool-call-badge")
.filter({ hasText: /^Thinking/ })
.first();
await expect(thought).toBeVisible();
await expect(reasoning).toHaveCount(0);
// A press right after the group opens occasionally does not register; retry until the
// thought row opens.
await expect(async () => {
await thought.getByRole("button").first().click();
await expect(reasoning).toBeVisible({ timeout: 2_000 });
}).toPass();
await expect(reasoning).toBeVisible();
}
}
@@ -6,7 +6,6 @@ for await (const line of lines) {
const command = JSON.parse(line);
let data = {};
if (command.type === "get_available_models") {
if (process.argv.includes("--stall-model-discovery")) continue;
data = {
models: [
{
@@ -5,64 +5,8 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { expectReconnectingToastVisible } from "./workspace-ui";
const PLUGIN_ID = "timeline-transform-e2e";
/** Observe the app's real socket, including fresh sockets after a reload. */
export function trackPluginChatRecovery(page: Page) {
const messages: {
type: string;
payload?: { plugins?: { id: string; clientBundle?: string }[] };
}[] = [];
page.on("websocket", (socket) => {
const collect = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.type === "session") messages.push(frame.message);
};
socket.on("framesent", collect);
socket.on("framereceived", collect);
});
return {
reset() {
messages.length = 0;
},
async expectCatalogWithoutRetransmission() {
await expect
.poll(() =>
messages.some(
(message) =>
message.type === "plugin.catalog.get.response" &&
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
),
)
.toBe(true);
const entry = messages
.find((message) => message.type === "plugin.catalog.get.response")
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
expect(entry).toBeDefined();
expect(entry).not.toHaveProperty("clientBundle");
},
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.toBe(true);
},
};
}
export async function reconnectPluginChat(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
gate.restore();
}
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
@@ -702,28 +702,6 @@ it("does not reopen the previous turn while a new run waits for its first row",
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
// the completed one; it must stay folded until the new turn's rows arrive.
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: null,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
const present = createStreamPresentation();
const timestamp = new Date();
+8 -10
View File
@@ -113,16 +113,14 @@ export function createStreamPresentation() {
}
// Live text updates reach the head only, so the folded history is reused until the
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
const last = projection.tail.at(-1) ?? null;
const first = projection.head[0] ?? null;
const activeTurnIsAnother = Boolean(
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
);
// Without head rows, the latest retained turn is live only while the active run is that
// turn. A run that has not opened yet carries no id and belongs to the next turn.
const isLatestTurnOpen = first
? !isTurnBoundary(last, first) && !activeTurnIsAnother
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
const isLatestTurnOpen =
(input.isTurnActive || projection.head.length > 0) &&
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
!(
input.activeTurnId &&
projection.tail.at(-1)?.turnId &&
input.activeTurnId !== projection.tail.at(-1)?.turnId
);
if (
!collapsed ||
collapsedSource !== projection.tail ||
+1 -3
View File
@@ -898,14 +898,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
? group.run.calls.map((call, index) => (
<React.Fragment key={call.id}>
{call.kind === "thought" ? (
// Reasoning is grouped only while Expand thinking is off, so an opened
// group shows each thought as a closed row like any other call.
<ThoughtSlot
itemId={call.id}
text={call.text}
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded={false}
defaultExpanded
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0,
},
emptyState: {
padding: theme.spacing[4],
paddingVertical: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[2],
},
@@ -1,32 +0,0 @@
import * as FileSystem from "expo-file-system/legacy";
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
import { createPluginClientBundleStore } from "./store";
export function createHostPluginClientBundleStore(serverId: string) {
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
const ensureDirectory = async () => {
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
};
return createPluginClientBundleStore(
{
async list() {
await ensureDirectory();
return FileSystem.readDirectoryAsync(directory);
},
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
async writeAtomic(name, contents) {
await ensureDirectory();
const temporary = `${directory}${randomUUID()}.tmp`;
try {
await FileSystem.writeAsStringAsync(temporary, contents);
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
} finally {
await FileSystem.deleteAsync(temporary, { idempotent: true });
}
},
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
},
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
);
}
@@ -1,62 +0,0 @@
import { createPluginClientBundleStore } from "./store";
const DATABASE_NAME = "paseo-plugin-client-bundles";
const STORE_NAME = "bundles";
let opening: Promise<IDBDatabase> | undefined;
function openDatabase(): Promise<IDBDatabase> {
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
}).catch((error) => {
opening = undefined;
throw error;
});
return opening;
}
export function createHostPluginClientBundleStore(serverId: string) {
const prefix = `${encodeURIComponent(serverId)}/`;
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
async function run<T>(
mode: IDBTransactionMode,
operation: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, mode);
const request = operation(transaction.objectStore(STORE_NAME));
transaction.addEventListener("complete", () => resolve(request.result));
transaction.addEventListener("abort", () => reject(transaction.error));
transaction.addEventListener("error", () => reject(transaction.error));
});
}
return createPluginClientBundleStore(
{
async list() {
return (await run("readonly", (store) => store.getAllKeys(range())))
.filter((key): key is string => typeof key === "string")
.map((key) => key.slice(prefix.length));
},
async read(name) {
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
return result;
},
async writeAtomic(name, contents) {
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
},
async remove(name) {
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
},
},
async (contents) => {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
},
);
}
@@ -1,36 +0,0 @@
import { expect, test } from "vitest";
import { createHostPluginClientBundleStore } from "./index.web";
async function sha256(contents: string) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
test("browser bundles survive a fresh store and remain isolated by host", async () => {
const first = createHostPluginClientBundleStore("bundle-test-host-a");
const second = createHostPluginClientBundleStore("bundle-test-host-b");
const clientBundle = "bundle".repeat(250_000);
const entry = {
id: "same-plugin",
clientBundle,
clientBundleHash: await sha256(clientBundle),
};
const otherCode = "other host code";
const other = {
id: entry.id,
clientBundle: otherCode,
clientBundleHash: await sha256(otherCode),
};
try {
await first.replace([entry]);
await second.replace([other]);
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
await first.replace([]);
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
expect(await second.load()).toEqual([other]);
} finally {
await first.replace([]);
await second.replace([]);
}
});
@@ -1,179 +0,0 @@
import { createHash } from "node:crypto";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { expect, test } from "vitest";
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
class BundleFiles implements PluginBundleFiles {
readonly contents = new Map<string, string>();
readonly writes: string[] = [];
async list() {
return [...this.contents.keys()];
}
async read(name: string) {
const contents = this.contents.get(name);
if (contents === undefined) throw new Error("File disappeared");
return contents;
}
async writeAtomic(name: string, contents: string) {
this.writes.push(name);
this.contents.set(name, contents);
}
async remove(name: string) {
this.contents.delete(name);
}
}
async function bundle(id: string, clientBundle: string) {
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
}
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
const files = new BundleFiles();
const old = await bundle("changed", "old");
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
const cold = createPluginClientBundleStore(files, sha256);
expect(await cold.load()).toEqual([old, kept]);
const changed = await bundle("changed", "new");
await cold.replace([kept, changed]);
expect(files.contents.size).toBe(2);
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
expect(files.writes).toHaveLength(3);
await cold.replace([]);
expect(files.contents.size).toBe(0);
});
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
const files = new BundleFiles();
const entries = await Promise.all([
bundle("same-length", "good"),
bundle("truncated", "long"),
bundle("missing", "lost"),
]);
await createPluginClientBundleStore(files, sha256).replace(entries);
const names = [...files.contents.keys()];
files.contents.set(names[0]!, "evil");
files.contents.set(names[1]!, "lo");
// The directory entry still exists while reading the file fails.
const unreadable: PluginBundleFiles = {
list: () => files.list(),
read: (name) =>
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
remove: (name) => files.remove(name),
};
const cold = createPluginClientBundleStore(unreadable, sha256);
expect(await cold.load()).toEqual([]);
await cold.replace(entries);
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
});
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
const files = new BundleFiles();
const previous = await bundle("plugin", "previous");
await createPluginClientBundleStore(files, sha256).replace([previous]);
const failing: PluginBundleFiles = {
list: () => files.list(),
read: (name) => files.read(name),
remove: (name) => files.remove(name),
writeAtomic: async () => {
throw new Error("Disk full");
},
};
await expect(
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
).rejects.toThrow("Disk full");
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
});
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
test.each(["valid", "corrupt", "missing"])(
"cold catalog uses a %s persisted bundle correctly",
async (state) => {
const files = new BundleFiles();
const entry = await bundle("plugin", "good code");
await createPluginClientBundleStore(files, sha256).replace([entry]);
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
if (state === "missing") files.contents.clear();
let open = () => {};
let receive: (data: unknown, binary: boolean) => void = () => {};
const requests: Record<string, unknown>[] = [];
const client = new DaemonClient({
url: "ws://test",
clientId: "plugin-cache-test",
reconnect: { enabled: false },
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
transportFactory: () => ({
send(data) {
if (typeof data !== "string") return;
const frame = JSON.parse(data);
if (frame.type !== "session") return;
const request = frame.message;
if (request.type !== "plugin.catalog.get.request") return;
requests.push(request);
const { clientBundle: _code, ...reference } = entry;
receive(
JSON.stringify({
type: "session",
message: {
type: "plugin.catalog.get.response",
payload: {
requestId: request.requestId,
plugins: [request.cachedClientBundles ? reference : entry],
},
},
}),
false,
);
},
close() {},
onOpen(handler) {
open = handler;
return () => {};
},
onMessage(handler) {
receive = handler;
return () => {};
},
onClose() {
return () => {};
},
onError() {
return () => {};
},
}),
});
try {
const connecting = client.connect();
open();
receive(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "cache-host",
hostname: null,
version: null,
features: { plugins: true },
},
},
}),
false,
);
await connecting;
expect(await client.getPluginCatalog()).toEqual([entry]);
if (state === "valid")
expect(requests[0]?.cachedClientBundles).toEqual([
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
]);
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
} finally {
await client.close();
}
},
);
@@ -1,56 +0,0 @@
import type {
PluginClientBundle,
PluginClientBundleStore,
} from "@getpaseo/client/internal/daemon-client";
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
export interface PluginBundleFiles {
list(): Promise<string[]>;
read(name: string): Promise<string>;
writeAtomic(name: string, contents: string): Promise<void>;
remove(name: string): Promise<void>;
}
export function createPluginClientBundleStore(
files: PluginBundleFiles,
sha256: (contents: string) => Promise<string>,
): PluginClientBundleStore {
const verified = new Set<string>();
const filename = (bundle: PluginClientBundle) =>
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
return {
async load() {
const bundles: PluginClientBundle[] = [];
for (const name of await files.list()) {
try {
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
if (!match) continue;
const id = decodeURIComponent(match[1]!);
const clientBundleHash = match[2]!;
const clientBundle = await files.read(name);
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
verified.add(name);
bundles.push({ id, clientBundleHash, clientBundle });
} catch {
// Missing, unreadable or corrupt files are cache misses, never executable code.
}
}
return bundles;
},
async replace(bundles) {
const retained = new Set<string>();
for (const bundle of bundles) {
const name = filename(bundle);
retained.add(name);
if (verified.has(name)) continue;
await files.writeAtomic(name, bundle.clientBundle);
verified.add(name);
}
for (const name of await files.list()) {
if (retained.has(name)) continue;
await files.remove(name);
verified.delete(name);
}
},
};
}
-4
View File
@@ -1,4 +1,3 @@
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
import { watchdog } from "@/watchdog";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
@@ -590,7 +589,6 @@ function createDefaultDeps(
capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
providerSnapshots: "wire",
createPluginClientBundleStore: createHostPluginClientBundleStore,
...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection),
} satisfies Omit<DaemonClientConfig, "url">;
@@ -670,7 +668,6 @@ function createDefaultDeps(
),
reconnect: { enabled: false },
appVersion: resolveAppVersion() ?? undefined,
createPluginClientBundleStore: createHostPluginClientBundleStore,
});
try {
await client.connect();
@@ -687,7 +684,6 @@ function createDefaultDeps(
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
createPluginClientBundleStore: createHostPluginClientBundleStore,
});
},
getClientId: () => getOrCreateClientId(),
-16
View File
@@ -371,22 +371,6 @@ describe("viewed timeline persistence", () => {
]);
});
it("preparing an already painted cache again keeps history out of the live head", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined },
prepareAgent: async () => undefined,
});
await replica.prepare(AGENT_ID);
const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID);
expect(painted).toEqual(cachedTimeline().items);
await replica.prepare(AGENT_ID);
const session = useSessionStore.getState().sessions[SERVER_ID]!;
expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]);
expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted);
});
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
@@ -109,15 +109,6 @@ class TimelineReplicaOwner implements TimelineReplica {
async prepare(agentId: string): Promise<void> {
const existing = this.preparations.get(agentId);
if (existing) return existing;
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[this.serverId],
agentId,
);
// A route and the visible-chat owner can both prepare before catch-up finishes.
// Repainting that same cache would treat its history as a live head.
if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) {
return;
}
const preparation = this.load(agentId).finally(() => {
if (this.preparations.get(agentId) === preparation) {
this.preparations.delete(agentId);
@@ -129,23 +129,6 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
});
it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const createPluginClientBundleStore = () => ({
async load() {
return [];
},
async replace() {},
});
const { client } = await connectToDaemon(
{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" },
{ createPluginClientBundleStore },
probe.deps,
);
expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore);
await client.close();
});
it("encodes the local socket target into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
@@ -132,13 +132,12 @@ function resolveConnectionCredentials(
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
options: {
options?: {
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
password?: string;
localCredential?: DaemonClientConfig["localCredential"];
} = {},
},
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
| "getClientId"
@@ -158,9 +157,8 @@ export async function buildClientConfig(
suppressSendErrors: true,
reconnect: { enabled: false },
...resolveConnectionCredentials(connection, options),
...(options.capabilities ? { capabilities: options.capabilities } : {}),
...(options.trace ? { trace: options.trace } : {}),
createPluginClientBundleStore: options.createPluginClientBundleStore,
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
...(options?.trace ? { trace: options.trace } : {}),
...((connection.type === "directSocket" || connection.type === "directPipe") &&
desktopTransportFactory
? { transportFactory: desktopTransportFactory }
@@ -290,7 +288,6 @@ interface ProbeOptions {
localCredential?: DaemonClientConfig["localCredential"];
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
}
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
+1 -185
View File
@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import {
DaemonClient,
@@ -9,8 +9,6 @@ import {
type CreateAgentRequestOptions,
type DaemonTransport,
type Logger,
type PluginClientBundle,
type PluginClientBundleStore,
} from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
@@ -2101,188 +2099,6 @@ test("gets a structured plugin log snapshot", async () => {
]);
});
describe("plugin catalog", () => {
async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
createPluginClientBundleStore: createStore,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { plugins: true } });
await connectPromise;
return { client, mock };
}
async function fetchCatalog(
harness: Awaited<ReturnType<typeof connectedClient>>,
respond: (request: Record<string, unknown>) => Array<Record<string, unknown>>,
) {
const before = harness.mock.sent.length;
const response = harness.client.getPluginCatalog();
await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before));
const request = parseSentFrame(harness.mock.sent.at(-1));
harness.mock.triggerMessage(
wrapSessionMessage({
type: "plugin.catalog.get.response",
payload: { requestId: request.requestId, plugins: respond(request) },
}),
);
return { request, plugins: await response };
}
test("reuses retained client bundles the daemon leaves out", async () => {
const harness = await connectedClient();
const first = await fetchCatalog(harness, () => [
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" },
]);
expect(first.request).not.toHaveProperty("cachedClientBundles");
const second = await fetchCatalog(harness, () => [
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
]);
expect(second.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundleHash: "hash-old" },
]);
expect(second.plugins).toEqual([
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
]);
const third = await fetchCatalog(harness, () => []);
expect(third.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundleHash: "hash-new" },
]);
const fourth = await fetchCatalog(harness, () => []);
expect(fourth.request).not.toHaveProperty("cachedClientBundles");
});
test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => {
const harness = await connectedClient();
const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }];
await fetchCatalog(harness, legacyCatalog);
const second = await fetchCatalog(harness, legacyCatalog);
expect(second.request).not.toHaveProperty("cachedClientBundles");
expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]);
});
test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => {
let persisted: PluginClientBundle[] = [];
let loads = 0;
const store: PluginClientBundleStore = {
async load() {
loads++;
return persisted;
},
async replace(bundles) {
persisted = bundles;
},
};
const createdFor: string[] = [];
const createStore = (serverId: string) => {
createdFor.push(serverId);
return store;
};
const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" };
const warm = await connectedClient(createStore);
await fetchCatalog(warm, () => [bundle]);
const cold = await connectedClient(createStore);
const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]);
expect(first.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
]);
expect(first.plugins).toEqual([bundle]);
expect(loads).toBe(2);
await fetchCatalog(cold, () => []);
expect(loads).toBe(2);
expect(createdFor).toEqual([
warm.client.getLastServerInfoMessage()!.serverId,
cold.client.getLastServerInfoMessage()!.serverId,
]);
expect(persisted).toEqual([]);
});
test.each(["missing", "unreadable"])(
"a %s persistent cache requests full bundles",
async (state) => {
const store: PluginClientBundleStore = {
async load() {
if (state === "unreadable") throw new Error("Storage unavailable");
return [];
},
async replace() {},
};
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]);
expect(result.request).not.toHaveProperty("cachedClientBundles");
expect(result.plugins).toEqual([bundle]);
},
);
test("failed cache writes never fail plugin loading", async () => {
const store: PluginClientBundleStore = {
async load() {
return [];
},
async replace() {
throw new Error("Disk full");
},
};
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
expect(
(await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins,
).toEqual([bundle]);
});
test.each([false, true])(
"usable catalogs do not wait for storage; latest write survives a failed first write: %s",
async (failFirstWrite) => {
const writes: PluginClientBundle[][] = [];
let finishWrite = () => {};
const blockedWrite = new Promise<void>((resolve) => {
finishWrite = resolve;
});
const store: PluginClientBundleStore = {
async load() {
return [];
},
async replace(bundles) {
writes.push(bundles);
if (writes.length === 1) {
await blockedWrite;
if (failFirstWrite) throw new Error("Disk full");
}
},
};
const harness = await connectedClient(() => store);
const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" };
const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" };
try {
expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]);
await vi.waitFor(() => expect(writes).toEqual([[first]]));
expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]);
expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]);
expect(writes).toEqual([[first]]);
finishWrite();
await vi.waitFor(() => expect(writes).toEqual([[first], []]));
} finally {
finishWrite();
}
},
);
});
test("keeps a plugin reload pending past the session RPC deadline", async () => {
useHeartbeatClock();
try {
+3 -99
View File
@@ -396,17 +396,6 @@ export type DaemonEventHandler = (event: DaemonEvent) => void;
export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest;
export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse;
export interface PluginClientBundle {
id: string;
clientBundleHash: string;
clientBundle: string;
}
/** Host-scoped storage. load must return only bundles whose contents have been verified. */
export interface PluginClientBundleStore {
load(): Promise<PluginClientBundle[]>;
replace(bundles: PluginClientBundle[]): Promise<void>;
}
/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */
export type RelaySecurityMode = "legacy" | "protected";
@@ -417,8 +406,6 @@ export interface DaemonClientConfig {
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
url: string;
clientId: string;
/** Create one cache for the confirmed daemon identity, retained for this client lifetime. */
createPluginClientBundleStore?: (serverId: string) => PluginClientBundleStore;
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
appVersion?: string;
runtimeGeneration?: number | null;
@@ -1029,12 +1016,6 @@ type CorrelatedResponsePayloads = {
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
CorrelatedResponsePayloads[TType];
type PluginCatalogResponseEntry =
CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number];
export type PluginCatalogEntry = Omit<PluginCatalogResponseEntry, "clientBundle"> & {
clientBundle: string;
};
export class DaemonConnectionError extends Error {
readonly serverId?: string;
readonly outcome?: "unknown";
@@ -1362,15 +1343,6 @@ export class DaemonClient {
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
private completedBinaryFileReads = new Map<string, FileReadResult>();
/** The last catalog's client bundles by plugin id, so later catalogs can leave them out. */
private pluginClientBundles = new Map<
string,
{ clientBundleHash: string; clientBundle: string }
>();
private pluginClientBundleStore: PluginClientBundleStore | undefined;
private pluginClientBundlesLoaded: Promise<void> | undefined;
private pluginClientBundlesWrite: PluginClientBundle[] | undefined;
private pluginClientBundlesWriting = false;
private logger: Logger;
private pendingSendQueue: PendingSend[] = [];
/**
@@ -5821,82 +5793,14 @@ export class DaemonClient {
});
}
async getPluginCatalog(): Promise<PluginCatalogEntry[]> {
if (this.config.createPluginClientBundleStore && !this.pluginClientBundleStore) {
const serverInfo = this.getLastServerInfoMessage();
if (!serverInfo) throw new Error("Plugin catalog requires a connected host");
this.pluginClientBundleStore = this.config.createPluginClientBundleStore(serverInfo.serverId);
}
const store = this.pluginClientBundleStore;
if (store) {
this.pluginClientBundlesLoaded ??= store.load().then(
(bundles) => {
for (const { id, ...bundle } of bundles) this.pluginClientBundles.set(id, bundle);
return undefined;
},
(error) => this.logger.warn({ err: error }, "Failed to load plugin client bundles"),
);
await this.pluginClientBundlesLoaded;
}
async getPluginCatalog() {
const requestId = this.createRequestId();
const cached = this.pluginClientBundles;
const cachedClientBundles = [...cached].map(([id, { clientBundleHash }]) => ({
id,
clientBundleHash,
}));
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.catalog.get.request",
requestId,
...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}),
},
message: { type: "plugin.catalog.get.request", requestId },
responseType: "plugin.catalog.get.response",
});
const retained = new Map<string, { clientBundleHash: string; clientBundle: string }>();
const plugins = payload.plugins.map(({ clientBundle: sent, ...plugin }): PluginCatalogEntry => {
const previous = cached.get(plugin.id);
const clientBundle =
sent ??
(previous?.clientBundleHash === plugin.clientBundleHash
? previous?.clientBundle
: undefined);
if (clientBundle === undefined) {
throw new Error(`Plugin catalog left out the client bundle for ${plugin.id}`);
}
// COMPAT(pluginCatalogBundleHash): introduced by PR #6732 (unreleased); remove after
// 2027-04-27 once the daemon floor includes bundle hashes. Older bundles are not retained.
if (plugin.clientBundleHash !== undefined) {
retained.set(plugin.id, { clientBundleHash: plugin.clientBundleHash, clientBundle });
}
return Object.assign(plugin, { clientBundle });
});
this.pluginClientBundles = retained;
if (store) {
this.pluginClientBundlesWrite = [...retained].map(([id, bundle]) =>
Object.assign({ id }, bundle),
);
void this.persistPluginClientBundles(store);
}
return plugins;
}
private async persistPluginClientBundles(store: PluginClientBundleStore): Promise<void> {
if (this.pluginClientBundlesWriting) return;
this.pluginClientBundlesWriting = true;
try {
while (this.pluginClientBundlesWrite) {
const bundles = this.pluginClientBundlesWrite;
this.pluginClientBundlesWrite = undefined;
try {
await store.replace(bundles);
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist plugin client bundles");
}
}
} finally {
this.pluginClientBundlesWriting = false;
}
return payload.plugins;
}
async listPlugins(): Promise<PluginListItem[]> {
@@ -23,29 +23,6 @@ describe("plugin protocol compatibility", () => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
},
);
it.each([
{ type: "plugin.catalog.get.request", requestId: "catalog" },
{
type: "plugin.catalog.get.request",
requestId: "catalog",
cachedClientBundles: [{ id: "example", clientBundleHash: "a".repeat(64) }],
},
])("parses plugin catalog requests %j", (request) => {
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
});
it("parses plugin catalogs that leave out a client bundle the client already holds", () => {
const message = {
type: "plugin.catalog.get.response",
payload: {
requestId: "catalog",
plugins: [
{ id: "cached", clientBundleHash: "a".repeat(64) },
{ id: "changed", clientBundle: "bundle", clientBundleHash: "b".repeat(64) },
],
},
};
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("preserves manifest metadata in plugin list responses", () => {
const message = {
type: "plugin.list.response",
+1 -8
View File
@@ -1470,10 +1470,6 @@ export const DiagnosticsRequestSchema = z.object({
export const PluginCatalogGetRequestSchema = z.object({
type: z.literal("plugin.catalog.get.request"),
requestId: z.string(),
/** Client bundles the client already holds; the daemon leaves out each one whose hash matches. */
cachedClientBundles: z
.array(z.object({ id: PluginIdSchema, clientBundleHash: z.string() }))
.optional(),
});
export const PluginListRequestSchema = z.object({
@@ -6761,10 +6757,7 @@ export const PluginCatalogGetResponseSchema = z.object({
plugins: z.array(
z.object({
id: PluginIdSchema,
/** Left out only when the request listed this plugin with the same `clientBundleHash`. */
clientBundle: z.string().optional(),
/** SHA-256 of `clientBundle`, hex encoded. */
clientBundleHash: z.string().optional(),
clientBundle: z.string(),
requirements: PluginRequirementsSchema.optional(),
}),
),
@@ -229,149 +229,6 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
return events.filter((event) => event.type === type);
}
function createRewindProviderHarness(options: { reuseAnswerId?: boolean } = {}) {
const nativeMessages: string[] = [];
const harness = createProviderHarness({
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
async handleInput(input, emit) {
if (input.type === "session.prompt") {
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
const text = input.prompt.input.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
nativeMessages.push(text);
const turnId = `turn-${text}`;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "turn", turnId },
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "user_message",
id: `user-${text}`,
text,
clientMessageId: input.prompt.clientMessageId,
revertToken: text,
},
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
return true;
}
if (input.type === "session.revert") {
if (input.scope !== "files")
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
return { ...harness, nativeMessages };
}
async function withRewindAgent(
run: (context: {
manager: AgentManager;
agentId: string;
nativeMessages: string[];
messages: () => string[];
}) => Promise<void>,
) {
const harness = createRewindProviderHarness();
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir() },
undefined,
{ workspaceId: undefined },
);
const messages = () =>
manager
.getTimeline(agent.id)
.flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
try {
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
} finally {
await manager.closeAgent(agent.id);
await registry.shutdown();
}
}
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "conversation");
const afterRewind = messages();
expect(nativeMessages).toEqual(["A"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
const afterReplacement = messages();
expect(nativeMessages).toEqual(["A", "D"]);
expect({ afterRewind, afterReplacement }).toEqual({
afterRewind: ["A", "Reply A"],
afterReplacement: ["A", "Reply A", "D", "Reply D"],
});
});
});
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "files");
expect(nativeMessages).toEqual(["A", "B", "C"]);
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
});
});
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
const harness = createRewindProviderHarness({ reuseAnswerId: true });
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
const live: AgentStreamEvent[] = [];
try {
await session.run("A");
await session.run("B");
await session.revertConversation!({ messageId: "user-B" });
session.subscribe((event) => live.push(event));
await session.run("B revised");
const assistant = live.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
expect(assistant).toEqual(["Reply B revised"]);
} finally {
await session.close();
await registry.shutdown();
}
});
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
for (const [sessionId, parentSessionId] of [
["a", rootSessionId],
@@ -78,45 +78,6 @@ test("listed client bundle is published while plugins are disabled; unlisted dir
}
}, 60_000);
test("a repeated catalog fetch leaves out the client bundle the client already holds", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-catalog-cache-"));
roots.push(root);
const directory = await fixture(root, "large", true);
// About the size of a real plugin client bundle.
await writeFile(
path.join(directory, "index.client.tsx"),
`const payload = ${JSON.stringify("x".repeat(1_000_000))};
export default function contribute() { void payload; return () => {}; }`,
);
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.9.2",
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(root, ["large"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
const responseBytes: number[] = [];
const unsubscribe = client.subscribeRawMessages((message) => {
if (message.type === "plugin.catalog.get.response") {
responseBytes.push(JSON.stringify(message).length);
}
});
try {
await client.connect();
const first = await client.getPluginCatalog();
const second = await client.getPluginCatalog();
expect(second).toEqual(first);
expect(second[0]?.clientBundle).toContain("x".repeat(1_000_000));
expect(responseBytes).toHaveLength(2);
expect(responseBytes[0]).toBeGreaterThan(1_000_000);
expect(responseBytes[1]).toBeLessThan(500);
} finally {
unsubscribe();
await client.close();
await daemon.close();
}
}, 60_000);
test("directory, Git, and npm installs reject a built-in ID", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-"));
roots.push(root);
@@ -1,5 +1,4 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -69,18 +68,9 @@ it("rejects incompatible installs without persisting them, then accepts the corr
await expect(service.installDirectory({ path: root })).resolves.toMatchObject({
status: "running",
});
const catalog = service.catalog();
expect(catalog).toEqual([
{
id: "example",
requirements: { paseo: "^0.8.0" },
clientBundle: expect.any(String),
clientBundleHash: expect.any(String),
},
expect(service.catalog()).toEqual([
{ id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) },
]);
expect(catalog[0]!.clientBundleHash).toBe(
createHash("sha256").update(catalog[0]!.clientBundle).digest("hex"),
);
});
it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => {
@@ -1,6 +1,5 @@
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { fork } from "node:child_process";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
import path from "node:path";
@@ -107,12 +106,6 @@ function createReloadChild(
};
}
function sha256(text: string | undefined): string {
return createHash("sha256")
.update(text ?? "")
.digest("hex");
}
function createTestRuntime(
dependencies: NonNullable<ConstructorParameters<typeof PluginRuntime>[2]> = {},
logger = pino({ level: "silent" }),
@@ -1543,7 +1536,6 @@ export default function contribute(server: { registerProvider(provider: Provider
expect(spawnChild).not.toHaveBeenCalled();
expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme");
expect(runtime.catalog()[0]?.clientBundleHash).toBe(sha256(runtime.catalog()[0]?.clientBundle));
await runtime.stopAll();
});
@@ -1706,7 +1698,6 @@ export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
const plugin = runtime.catalog()[0];
expect(plugin?.clientBundle).toContain("Client-only surface");
expect(plugin?.clientBundle).not.toContain("node:os");
expect(plugin?.clientBundleHash).toBe(sha256(plugin?.clientBundle));
await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({
platform: expect.any(String),
});
+4 -23
View File
@@ -15,7 +15,7 @@ import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.j
import { fork } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import type pino from "pino";
import {
PROVIDER_CAPABILITIES,
@@ -74,8 +74,6 @@ interface LoadedPlugin {
instanceId: string;
requirements: PluginRequirements | undefined;
clientBundle: string;
/** Lets a client that already holds this bundle skip downloading it again. */
clientBundleHash: string;
methods: ReadonlySet<string>;
hooks: { events: string[]; before: string[] };
providers: readonly PluginProviderMetadata[];
@@ -248,10 +246,6 @@ function terminatePluginChild(child: PluginChild): void {
if (!child.killed) child.kill();
}
function hashClientBundle(clientBundle: string): string {
return createHash("sha256").update(clientBundle).digest("hex");
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -375,19 +369,9 @@ export class PluginRuntime {
return true;
}
catalog(): Array<{
id: string;
clientBundle: string;
clientBundleHash: string;
requirements?: PluginRequirements;
}> {
catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> {
return [...this.plugins.values()]
.map(({ id, clientBundle, clientBundleHash, requirements }) => ({
id,
clientBundle,
clientBundleHash,
requirements,
}))
.map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements }))
.sort((left, right) => left.id.localeCompare(right.id));
}
@@ -755,12 +739,10 @@ export class PluginRuntime {
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
const serverBundle = bundles.serverBundle;
if (!serverBundle) {
const clientBundle = bundles.clientBundle ?? "";
return {
id: pluginId,
instanceId: randomUUID(),
clientBundle,
clientBundleHash: hashClientBundle(clientBundle),
clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements,
methods: new Set(),
hooks: { events: [], before: [] },
@@ -890,7 +872,6 @@ export class PluginRuntime {
id: pluginId,
instanceId: randomUUID(),
clientBundle,
clientBundleHash: hashClientBundle(clientBundle),
requirements,
methods: new Set(ready.methods),
hooks: ready.hooks ?? { events: [], before: [] },
@@ -588,78 +588,6 @@ test("routes plugin requests and releases its owned catalog subscription on clea
expect(releasePluginSubscription).toHaveBeenCalledOnce();
});
test("leaves out the client bundles a catalog request already holds", async () => {
const messages: SessionOutboundMessage[] = [];
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
before: async (_name, request) => request,
emit: () => {},
listPlugins: async () => [],
getLogs: () => [],
installDirectory: async () => {
throw new Error("Unexpected install");
},
inspectDirectory: async () => ({ id: "unused" }),
reloadPlugin: async () => {
throw new Error("Unexpected reload");
},
enablePlugin: async () => {
throw new Error("Unexpected enable");
},
disablePlugin: async () => {
throw new Error("Unexpected disable");
},
removePlugin: async () => undefined,
subscribe: () => () => {},
catalog: () => [
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
],
invokePluginRpc: async () => null,
listUsageReports: async () => [],
listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }),
};
const session = createSessionForTest({ messages, pluginRuntime });
await session.handleMessage({
type: "plugin.catalog.get.request",
requestId: "reconnect",
cachedClientBundles: [
{ id: "cached", clientBundleHash: "hash-cached" },
{ id: "changed", clientBundleHash: "hash-old" },
{ id: "removed", clientBundleHash: "hash-removed" },
],
});
// Clients from before cachedClientBundles always receive every bundle.
await session.handleMessage({ type: "plugin.catalog.get.request", requestId: "legacy" });
expect(messages).toEqual([
{
type: "plugin.catalog.get.response",
payload: {
requestId: "reconnect",
plugins: [
{ id: "cached", clientBundleHash: "hash-cached" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
],
},
},
{
type: "plugin.catalog.get.response",
payload: {
requestId: "legacy",
plugins: [
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
],
},
},
]);
await session.cleanup();
});
describe("workspace label subscriptions", () => {
type LabelSubscription = Awaited<ReturnType<WorkspaceLabelService["subscribe"]>>;
type LabelChange = Parameters<Parameters<WorkspaceLabelService["subscribe"]>[0]["onChange"]>[0];
+5 -10
View File
@@ -518,7 +518,7 @@ export interface SessionOptions {
removePlugin(pluginId: string): Promise<void>;
subscribe(listener: (pluginId: string) => void): () => void;
subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void;
catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>;
catalog(): Array<{ id: string; clientBundle: string }>;
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
listUsageReports(options?: {
forceRefresh?: boolean;
@@ -2584,17 +2584,12 @@ export class Session {
});
}
if (msg.type === "plugin.catalog.get.request") {
const cached = new Map(
msg.cachedClientBundles?.map((entry) => [entry.id, entry.clientBundleHash]),
);
const plugins = (this.pluginRuntime?.catalog() ?? []).map(({ clientBundle, ...plugin }) =>
plugin.clientBundleHash !== undefined && cached.get(plugin.id) === plugin.clientBundleHash
? plugin
: Object.assign(plugin, { clientBundle }),
);
this.emit({
type: "plugin.catalog.get.response",
payload: { requestId: msg.requestId, plugins },
payload: {
requestId: msg.requestId,
plugins: this.pluginRuntime?.catalog() ?? [],
},
});
return undefined;
}