Restore provider readiness for mobile plugins (#6760)

* fix(client): restore provider readiness on mobile

React Native uses an AbortSignal without throwIfAborted. Check aborted with the existing API disposal error so readiness works while preserving cleanup and cancellation.

Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>

* test(plugins): run mobile readiness regression in CI

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-13 06:58:33 +02:00
committed by GitHub
co-authored by GeorgeR
parent ec6393f31f
commit 1839de5b1d
2 changed files with 58 additions and 1 deletions
+1 -1
View File
@@ -1051,7 +1051,7 @@ async function waitForProvidersReady(
try {
await observation.ready;
signal.throwIfAborted();
if (signal.aborted) throw new Error("Paseo API is disposed");
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
let settled = false;
let requestId: string | null = null;
@@ -1,3 +1,7 @@
import { createRequire } from "node:module";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { EventEmitter, once } from "node:events";
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -15,6 +19,59 @@ afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test.each(["React Native", "native"])(
"provider readiness works with %s AbortController",
async (runtime) => {
const require = createRequire(import.meta.url);
const reactNativeRequire = createRequire(require.resolve("react-native/package.json"));
const { AbortController: ReactNativeAbortController } = reactNativeRequire(
"abort-controller/dist/abort-controller",
);
const bundle = await build({
entryPoints: [fileURLToPath(new URL("../../../../client/src/index.ts", import.meta.url))],
bundle: true,
write: false,
format: "iife",
globalName: "paseoSdk",
platform: "browser",
});
// A separate runtime starts with React Native's real abort implementation.
// Node's globals and the daemon's runtime remain unchanged.
const sdk: typeof import("@getpaseo/client") = runInNewContext(
`${bundle.outputFiles[0]!.text}\npaseoSdk`,
{
AbortController: runtime === "React Native" ? ReactNativeAbortController : AbortController,
setTimeout,
clearTimeout,
queueMicrotask,
TextEncoder,
TextDecoder,
},
);
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const api = sdk.createPaseoApi(client);
try {
await client.connect();
const snapshot = await api.providers.waitForReady({ cwd: daemon.staticDir });
expect(snapshot.entries.length).toBeGreaterThan(0);
expect(snapshot.entries.every((entry) => entry.status !== "loading")).toBe(true);
const waiting = api.providers.waitForReady({ cwd: daemon.staticDir });
const canceled = expect(waiting).rejects.toThrow(/disposed|released/);
await api.dispose();
await canceled;
const { diagnostic } = await client.collectDiagnostics();
expect(diagnostic).toContain("Source registrations: 0");
expect(diagnostic).toContain("Producer listeners: 0");
expect(() => api.providers.waitForReady()).toThrow("disposed");
} finally {
await api.dispose();
await client.close();
await daemon.close();
}
},
);
test("plugin handlers create workspaces and agents through their Paseo API", async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-"));
const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-"));