Compare commits

..
Author SHA1 Message Date
abc23d7721 perf(plugins): minify client bundles while preserving interop and notices (#8076)
Reduce plugin client payloads with whitespace and syntax minification after eager host-export interop. Preserve identifiers, lowered async behavior and dependency license notices; leave server bundles readable.

Verify worker-produced code through registration, byte-reduction, immutable-export and retained-notice regressions. Carries forward #7625 on an upstream branch.

Pin Playwright-installing CI jobs to Ubuntu 24.04 after the ubuntu-latest rollout selected an unsupported browser-install platform.

Follow-up payload review motivated by Matt Cowger's plugin-loading discussion: https://discord.com/channels/1481169421832814616/1563217536269680744

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-28 09:50:29 +07:00
paseo-bot[bot] 2c23cd4927 test(cli): let test daemons allocate their listening ports (#8058)
Reserve-and-release port selection races other listeners during daemon startup. Bind port zero and use the existing daemon instance endpoint for readiness and callers.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 21:27:30 +00:00
6 changed files with 165 additions and 178 deletions
+11 -10
View File
@@ -133,11 +133,12 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run typecheck:examples --workspace=@getpaseo/client
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
typecheck:
name: typecheck
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -395,7 +396,7 @@ jobs:
name: app-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -429,7 +430,7 @@ jobs:
name: playwright (shard 1/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "1/8"
PLAYWRIGHT_ARTIFACT: "1"
@@ -485,7 +486,7 @@ jobs:
name: playwright (shard 2/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "2/8"
PLAYWRIGHT_ARTIFACT: "2"
@@ -495,7 +496,7 @@ jobs:
name: playwright (shard 3/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "3/8"
PLAYWRIGHT_ARTIFACT: "3"
@@ -505,7 +506,7 @@ jobs:
name: playwright (shard 4/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "4/8"
PLAYWRIGHT_ARTIFACT: "4"
@@ -515,7 +516,7 @@ jobs:
name: playwright (shard 5/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "5/8"
PLAYWRIGHT_ARTIFACT: "5"
@@ -525,7 +526,7 @@ jobs:
name: playwright (shard 6/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "6/8"
PLAYWRIGHT_ARTIFACT: "6"
@@ -535,7 +536,7 @@ jobs:
name: playwright (shard 7/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "7/8"
PLAYWRIGHT_ARTIFACT: "7"
@@ -545,7 +546,7 @@ jobs:
name: playwright (shard 8/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "8/8"
PLAYWRIGHT_ARTIFACT: "8"
@@ -0,0 +1,16 @@
#!/usr/bin/env npx tsx
import assert from "node:assert/strict";
import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts";
// Port zero leaves allocation to the listening daemon, with no reserve/release gap.
const daemon = await startTestDaemon({ port: 0 });
try {
assert(daemon.port > 0, "the helper returns the port the daemon actually bound");
assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`);
const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]);
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), []);
} finally {
await daemon.stop();
}
+22 -24
View File
@@ -18,7 +18,7 @@ import { tmpdir } from "os";
import { join } from "path";
import { fileURLToPath } from "url";
import { ChildProcess, spawn } from "child_process";
import { getAvailablePort } from "./network.ts";
import { readDaemonInstance } from "@getpaseo/server/daemon-control";
export interface TestDaemonContext {
/** Available local port for test daemon (never 6767) */
@@ -195,17 +195,20 @@ async function probeDaemonReady(
}
async function waitForDaemonReady(
port: number,
paseoHome: string,
timeout = 30000,
env?: NodeJS.ProcessEnv,
): Promise<void> {
): Promise<number> {
const deadline = Date.now() + timeout;
async function poll(): Promise<void> {
if (await probeDaemonReady(port, paseoHome, env)) return;
async function poll(): Promise<number> {
const instance = await readDaemonInstance(paseoHome);
if (instance?.listen) {
const port = Number(new URL(`http://${instance.listen}`).port);
if (await probeDaemonReady(port, paseoHome, env)) return port;
}
if (Date.now() >= deadline) {
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`);
}
await sleep(100);
return poll();
@@ -231,15 +234,14 @@ export async function startTestDaemon(options?: {
timeout?: number;
env?: NodeJS.ProcessEnv;
}): Promise<TestDaemonContext> {
const port = options?.port ?? (await getAvailablePort());
// Let the listening process allocate its port, without a reserve/release race.
const requestedPort = options?.port ?? 0;
const { paseoHome, workDir } =
options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs();
const timeout = options?.timeout ?? 30000;
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`;
// Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, "..", "..");
const cliSrcPath = join(cliDir, "src", "index.ts");
@@ -252,7 +254,7 @@ export async function startTestDaemon(options?: {
),
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`,
// Force no TTY to prevent QR code output
CI: "true",
...options?.env,
@@ -312,20 +314,18 @@ export async function startTestDaemon(options?: {
}
});
const ctx: TestDaemonContext = {
port,
wsUrl,
paseoHome,
workDir,
process: daemonProcess,
isReady: false,
stop: cleanup,
};
// Wait for daemon to be ready
try {
await waitForDaemonReady(port, paseoHome, timeout, options?.env);
ctx.isReady = true;
const port = await waitForDaemonReady(paseoHome, timeout, options?.env);
return {
port,
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
paseoHome,
workDir,
process: daemonProcess,
isReady: true,
stop: cleanup,
};
} catch (err) {
// Daemon failed to start - clean up and rethrow
await cleanup();
@@ -335,8 +335,6 @@ export async function startTestDaemon(options?: {
{ cause: err },
);
}
return ctx;
}
/**
@@ -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],
@@ -257,6 +257,22 @@ function makeHermesInteropEager(code: string): string {
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
async function minifyClientBundle(code: string): Promise<string> {
const { transform } = loadEsbuild();
// Minify after the eager replace. Whitespace minify rewrites the getter this
// replace matches, and identifier minify can delete the helper entirely.
const result = await transform(code, {
minifyWhitespace: true,
minifySyntax: true,
minifyIdentifiers: false,
target: "es2020",
supported: { "async-await": false },
charset: "ascii",
legalComments: "inline",
});
return result.code;
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
@@ -368,7 +384,10 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
const eager = makeHermesInteropEager(output);
let prepared = eager;
if (target === "client") prepared = await minifyClientBundle(eager);
return wrapCommonJsBundle(prepared);
}
export async function compilePlugin(entryPaths: {
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import * as zod from "zod";
import { ZodError } from "zod";
import {
compilePlugin,
@@ -569,6 +570,101 @@ export default function contribute(client) {
expect(clientBundle).toContain("__async");
});
it("reduces client bytes while preserving registration and leaving server output readable", async () => {
const entries = await createSplitPlugin();
const source = `import { z } from "zod";
const commandSchema = z.object({ id: z.string(), title: z.string() });
export default function contribute(client) {
const command = commandSchema.parse({ id: "open", title: "Open – plugin" });
client.addCommandCenterItem({
...command,
icon: "Scan",
context: "global",
onSelect() { return Promise.resolve(command.title); },
});
return () => undefined;
}`;
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
const { clientBundle, serverBundle } = await compilePlugin(entries);
if (clientBundle === null || serverBundle === null) {
throw new Error("compilePlugin returned no bundle");
}
for (const bundle of [clientBundle, serverBundle]) {
const registered: { id: string; title: string; onSelect: () => Promise<string> }[] = [];
const exports = new Function(`return ${bundle}`)()((specifier: string) => {
expect(specifier).toBe("zod");
return zod;
});
const cleanup = exports.default({
addCommandCenterItem: (command: (typeof registered)[number]) => registered.push(command),
});
expect(registered).toMatchObject([{ id: "open", title: "Open – plugin" }]);
await expect(registered[0]!.onSelect()).resolves.toBe("Open – plugin");
expect(cleanup()).toBeUndefined();
}
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
expect(clientBundle.split("").every((char) => char.charCodeAt(0) <= 0x7f)).toBe(true);
expect(serverBundle).toContain('title: "Open');
});
it("retains dependency license notices while minifying executable client code", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/licensed-dependency");
const notice = "Copyright Example Authors. SPDX-License-Identifier: MIT";
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "licensed-dependency", main: "index.js" }),
);
await writeFile(
path.join(dependency, "index.js"),
`/*! ${notice} */
exports.title = function title(input) {
const trimmed = input.trim();
return "Licensed " + trimmed;
};`,
);
const source = `import { title } from "licensed-dependency";
export default function contribute() { return title(" plugin "); }`;
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
const { clientBundle, serverBundle } = await compilePlugin(entries);
if (clientBundle === null || serverBundle === null) {
throw new Error("compilePlugin returned no bundle");
}
for (const bundle of [clientBundle, serverBundle]) {
expect(bundle).toContain(notice);
const exports = new Function(`return ${bundle}`)()(() => {
throw new Error("The dependency must be bundled");
});
expect(exports.default()).toBe("Licensed plugin");
}
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
});
it("keeps distinct host exports eager when minifying in the worker", async () => {
const entries = await createSplitPlugin();
await writeFile(
entries.client,
`import * as host from "zod";
export default function contribute() { return [host.first, host.second]; }`,
);
const { clientBundle } = await compilePlugin({ client: entries.client, server: null });
const host = { first: "first export", second: "second export" };
const factory = new Function(`return ${clientBundle}`)() as (
require: (specifier: string) => typeof host,
) => { default: () => unknown };
function requireHost(specifier: string): typeof host {
expect(specifier).toBe("zod");
return host;
}
const exports = factory(requireHost);
host.first = "changed first export";
host.second = "changed second export";
expect(exports.default()).toEqual(["first export", "second export"]);
});
it("rejects node imports from the client entry", async () => {
const entries = await createSplitPlugin();
await writeFile(