Compare commits

...
4 Commits
Author SHA1 Message Date
paseo-bot[bot]andMatteo Pietro Dazzi de50243885 Keep model-discovery errors away from picker edges (#8039)
* fix(app): pad the model picker empty state on all sides

The empty state carried vertical padding only, so the provider refresh
error it renders wrapped from edge to edge and read as attached to the
model picker's left border on web and mobile.

One padding value keeps the message off the border on every platform.

* test(app): keep model discovery errors inside picker edges

Exercise a real catalog deadline through the existing Pi RPC fixture at desktop and compact browser widths. Both gutter assertions fail without the shared padding fix.

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>

---------

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 15:59:03 +00:00
Mohamed Boudra 5b7b8d6e81 fix(app): regenerate iOS Markdown inline code props (#8038) 2026-10-27 22:44:49 +07:00
Mohamed BoudraandClaude 25721c7847 fix(app): keep grouped reasoning closed when Expand thinking is off (#8042)
Thoughts are grouped with tool calls only while Expand thinking is off,
yet opening a group rendered each thought with `defaultExpanded`, so the
reasoning text popped open against the setting. Grouped thoughts now open
like any other call: a closed Thinking row the reader clicks.

The folded history also treated the latest retained turn as live whenever
the agent was active without a known turn id, which unfolded the previous
turn until the new turn's rows arrived. Without head rows, the latest turn
stays live only while the active run is that turn.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-27 22:34:13 +07:00
78f580f9e8 perf(plugins): reuse verified bundles and fix repeated timeline preparation (#8040)
Reuse verified host-scoped plugin bundles across client launches and reconstruct complete catalogs when unchanged code is omitted. Keep store ownership on the connected client, including adopted bootstrap clients, and persist in the background with bounded writes.

Remove catalog catch-up deferral and fix repeated timeline preparation that promoted cached completed history into a live head. This preserves completed plugin-rendered chat when catalog installation precedes timeline catch-up.

Carries forward #6732 on an upstream branch, with the original hash/cache contribution and integration attribution preserved.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-27 22:00:23 +07:00
32 changed files with 1182 additions and 31 deletions
+9
View File
@@ -382,6 +382,15 @@ 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,4 +1,5 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
withTimelinePlugin,
viewInlineThinkingExample,
@@ -6,6 +7,8 @@ import {
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -13,13 +16,24 @@ 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,5 +1,7 @@
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";
@@ -214,3 +216,66 @@ 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);
});
}
});
+14 -1
View File
@@ -475,7 +475,20 @@ 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();
await expect(reasoning).toBeVisible();
// 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();
}
}
@@ -6,6 +6,7 @@ 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,8 +5,64 @@ 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,6 +702,28 @@ 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();
+10 -8
View File
@@ -113,14 +113,16 @@ 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 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
);
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);
if (
!collapsed ||
collapsedSource !== projection.tail ||
+3 -1
View File
@@ -898,12 +898,14 @@ 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
defaultExpanded={false}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0,
},
emptyState: {
paddingVertical: theme.spacing[4],
padding: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[2],
},
@@ -0,0 +1,32 @@
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),
);
}
@@ -0,0 +1,62 @@
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(
"",
);
},
);
}
@@ -0,0 +1,36 @@
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([]);
}
});
@@ -0,0 +1,179 @@
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();
}
},
);
@@ -0,0 +1,56 @@
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,3 +1,4 @@
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
import { watchdog } from "@/watchdog";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
@@ -589,6 +590,7 @@ 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">;
@@ -668,6 +670,7 @@ function createDefaultDeps(
),
reconnect: { enabled: false },
appVersion: resolveAppVersion() ?? undefined,
createPluginClientBundleStore: createHostPluginClientBundleStore,
});
try {
await client.connect();
@@ -684,6 +687,7 @@ function createDefaultDeps(
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
createPluginClientBundleStore: createHostPluginClientBundleStore,
});
},
getClientId: () => getOrCreateClientId(),
+16
View File
@@ -371,6 +371,22 @@ 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,6 +109,15 @@ 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,6 +129,23 @@ 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,12 +132,13 @@ 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"
@@ -157,8 +158,9 @@ export async function buildClientConfig(
suppressSendErrors: true,
reconnect: { enabled: false },
...resolveConnectionCredentials(connection, options),
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
...(options?.trace ? { trace: options.trace } : {}),
...(options.capabilities ? { capabilities: options.capabilities } : {}),
...(options.trace ? { trace: options.trace } : {}),
createPluginClientBundleStore: options.createPluginClientBundleStore,
...((connection.type === "directSocket" || connection.type === "directPipe") &&
desktopTransportFactory
? { transportFactory: desktopTransportFactory }
@@ -288,6 +290,7 @@ interface ProbeOptions {
localCredential?: DaemonClientConfig["localCredential"];
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
}
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
+185 -1
View File
@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import {
DaemonClient,
@@ -9,6 +9,8 @@ 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";
@@ -2099,6 +2101,188 @@ 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 {
+99 -3
View File
@@ -396,6 +396,17 @@ 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";
@@ -406,6 +417,8 @@ 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;
@@ -1016,6 +1029,12 @@ 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";
@@ -1343,6 +1362,15 @@ 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[] = [];
/**
@@ -5793,14 +5821,82 @@ export class DaemonClient {
});
}
async getPluginCatalog() {
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;
}
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 },
message: {
type: "plugin.catalog.get.request",
requestId,
...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}),
},
responseType: "plugin.catalog.get.response",
});
return payload.plugins;
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;
}
}
async listPlugins(): Promise<PluginListItem[]> {
@@ -23,6 +23,29 @@ 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",
+8 -1
View File
@@ -1470,6 +1470,10 @@ 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({
@@ -6757,7 +6761,10 @@ export const PluginCatalogGetResponseSchema = z.object({
plugins: z.array(
z.object({
id: PluginIdSchema,
clientBundle: z.string(),
/** 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(),
requirements: PluginRequirementsSchema.optional(),
}),
),
@@ -78,6 +78,45 @@ 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,4 +1,5 @@
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";
@@ -68,9 +69,18 @@ it("rejects incompatible installs without persisting them, then accepts the corr
await expect(service.installDirectory({ path: root })).resolves.toMatchObject({
status: "running",
});
expect(service.catalog()).toEqual([
{ id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) },
const catalog = service.catalog();
expect(catalog).toEqual([
{
id: "example",
requirements: { paseo: "^0.8.0" },
clientBundle: expect.any(String),
clientBundleHash: 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,5 +1,6 @@
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";
@@ -106,6 +107,12 @@ 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" }),
@@ -1536,6 +1543,7 @@ 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();
});
@@ -1698,6 +1706,7 @@ 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),
});
+23 -4
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 { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import type pino from "pino";
import {
PROVIDER_CAPABILITIES,
@@ -74,6 +74,8 @@ 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[];
@@ -246,6 +248,10 @@ 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);
}
@@ -369,9 +375,19 @@ export class PluginRuntime {
return true;
}
catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> {
catalog(): Array<{
id: string;
clientBundle: string;
clientBundleHash: string;
requirements?: PluginRequirements;
}> {
return [...this.plugins.values()]
.map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements }))
.map(({ id, clientBundle, clientBundleHash, requirements }) => ({
id,
clientBundle,
clientBundleHash,
requirements,
}))
.sort((left, right) => left.id.localeCompare(right.id));
}
@@ -739,10 +755,12 @@ 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: bundles.clientBundle ?? "",
clientBundle,
clientBundleHash: hashClientBundle(clientBundle),
requirements: manifest.requirements,
methods: new Set(),
hooks: { events: [], before: [] },
@@ -872,6 +890,7 @@ export class PluginRuntime {
id: pluginId,
instanceId: randomUUID(),
clientBundle,
clientBundleHash: hashClientBundle(clientBundle),
requirements,
methods: new Set(ready.methods),
hooks: ready.hooks ?? { events: [], before: [] },
@@ -588,6 +588,78 @@ 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];
+10 -5
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 }>;
catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>;
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
listUsageReports(options?: {
forceRefresh?: boolean;
@@ -2584,12 +2584,17 @@ 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: this.pluginRuntime?.catalog() ?? [],
},
payload: { requestId: msg.requestId, plugins },
});
return undefined;
}
@@ -3770,3 +3770,60 @@ index eaa3279..05a02eb 100644
private:
int forceHeightRecalculationCounter_{0};
std::shared_ptr<MarkdownTextMeasurementManager> measurementsManager_;
diff --git a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
--- a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
+++ b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
@@ -1639,6 +1639,7 @@
struct EnrichedMarkdownMarkdownStyleCodeStruct {
std::string fontFamily{};
Float fontSize{0.0};
+ Float fontScale{0.0};
SharedColor color{};
SharedColor backgroundColor{};
SharedColor borderColor{};
@@ -1651,6 +1652,7 @@
folly::dynamic result = folly::dynamic::object();
result["fontFamily"] = fontFamily;
result["fontSize"] = fontSize;
+ result["fontScale"] = fontScale;
result["color"] = ::facebook::react::toDynamic(color);
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
@@ -1670,6 +1672,10 @@
if (tmp_fontSize != map.end()) {
fromRawValue(context, tmp_fontSize->second, result.fontSize);
}
+ auto tmp_fontScale = map.find("fontScale");
+ if (tmp_fontScale != map.end()) {
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
+ }
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
fromRawValue(context, tmp_color->second, result.color);
@@ -6185,6 +6191,7 @@
struct EnrichedMarkdownTextMarkdownStyleCodeStruct {
std::string fontFamily{};
Float fontSize{0.0};
+ Float fontScale{0.0};
SharedColor color{};
SharedColor backgroundColor{};
SharedColor borderColor{};
@@ -6197,6 +6204,7 @@
folly::dynamic result = folly::dynamic::object();
result["fontFamily"] = fontFamily;
result["fontSize"] = fontSize;
+ result["fontScale"] = fontScale;
result["color"] = ::facebook::react::toDynamic(color);
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
@@ -6216,6 +6224,10 @@
if (tmp_fontSize != map.end()) {
fromRawValue(context, tmp_fontSize->second, result.fontSize);
}
+ auto tmp_fontScale = map.find("fontScale");
+ if (tmp_fontScale != map.end()) {
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
+ }
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
fromRawValue(context, tmp_color->second, result.color);
+32
View File
@@ -10,6 +10,38 @@ const bin = path.resolve("node_modules", ".bin");
const packageName = "react-native-draggable-flatlist";
const patchFile = `${packageName}+4.0.3.patch`;
test("patched Markdown ships iOS props generated from its native specs", () => {
const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown");
const output = mkdtempSync(path.join(tmpdir(), "markdown-ios-codegen-"));
try {
// includesGeneratedCode makes CocoaPods use the shipped headers instead of
// regenerating them after patch-package changes the TypeScript specs.
const result = spawnSync(
process.execPath,
[
path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"),
"-p",
packageDir,
"-t",
"ios",
"-o",
output,
"-s",
"library",
],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stdout + result.stderr);
const header = "ReactCodegen/EnrichedMarkdownTextSpec/Props.h";
assert.equal(
readFileSync(path.join(packageDir, "ios/generated", header), "utf8"),
readFileSync(path.join(output, header), "utf8"),
);
} finally {
rmSync(output, { recursive: true, force: true });
}
});
function createCheckout() {
const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" }));