/** * isolated-provider.e2e.test.ts — reachability guard for PR #152 (issue #151). * * PR #152 fixes isolated subagents dropping extension-registered custom providers * on Pi >= 0.80.8 (the `modelRegistry` → `modelRuntime` migration). agent-runner * forwards the parent's runtime, read off the ModelRegistry facade as * `ctx.modelRegistry.runtime` via `as unknown as { runtime }`. * * That forwarding is already guarded by the unit test in test/agent-runner.test.ts * ("passes the parent model runtime …") — but against a MOCK whose `.runtime` is * hand-set. The mock cannot catch the one thing that would silently break the fix: * `.runtime` is a `private readonly` field on the real ModelRegistry, absent from * the public type AND the package exports. If a future Pi renames it, makes it a * true #private, or moves the module, the cast quietly yields `undefined`, the fix * omits `modelRuntime`, and the bug returns with no failing test. * * This test closes exactly that gap and nothing else: it asserts the real facade * exposes a runtime-reachable `.runtime` that IS the runtime it wraps. It is not a * guard for the forwarding itself (that's the unit test's job) — a fuller e2e that * drives real `runAgent` end-to-end is tracked as a follow-up. * * VERSION GATE: `.runtime` only exists in the post-migration facade world (Pi >= * 0.80.8, where `ModelRuntime` is first exported). The repo's dev dependency is * pinned post-migration, so this runs by default — but it stays gated because CI * also runs the suite against the peer-range floor (see .github/workflows/ci.yml), * where Pi predates the migration. There we DYNAMICALLY import Pi and skip * cleanly; a static `import { ModelRuntime }` would be a link-time error. */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, describe, expect, it } from "vitest"; // Dynamic, so this file LOADS on pre-migration Pi (a static `import { ModelRuntime }` // would be a link-time error there — 0.80.6 doesn't export it). const pi = (await import("@earendil-works/pi-coding-agent")) as Record; const ModelRuntime = pi.ModelRuntime as | { create(opts?: Record): Promise } | undefined; // The migration is exactly "ModelRuntime now exists". Absent ⇒ pre-0.80.8 ⇒ there // is no `.runtime` facade to guard. const MIGRATED = typeof ModelRuntime?.create === "function"; const RT = ModelRuntime as { create(opts?: Record): Promise }; // The one method the reach scenario needs; `.runtime` itself is private (reached below). interface ModelRuntimeLike { registerProvider(id: string, config: Record): void; } const tmpDirs: string[] = []; afterAll(() => { for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); describe.skipIf(!MIGRATED)("PR #152 reach: real ModelRegistry exposes .runtime (Pi >= 0.80.8)", () => { it("ctx.modelRegistry.runtime is reachable and IS the runtime it wraps", async () => { // A real, configured runtime — as an extension leaves it after registerProvider. const dir = mkdtempSync(join(tmpdir(), "iso-prov-")); tmpDirs.push(dir); const runtime = await RT.create({ authPath: join(dir, "auth.json"), modelsPath: join(dir, "models.json"), allowModelNetwork: false, }); // `.runtime` is private and not in the package exports — reach the compiled // class by file path, exactly the field the patch's cast depends on. If Pi // moves/renames/#privates it, THIS line fails loudly instead of the fix // silently no-op'ing back to the #151 bug. const indexUrl = import.meta.resolve("@earendil-works/pi-coding-agent"); const mrUrl = indexUrl.replace(/index\.js$/, "core/model-registry.js"); const { ModelRegistry } = (await import(mrUrl)) as { ModelRegistry: new (rt: ModelRuntimeLike) => { runtime?: unknown }; }; const facade = new ModelRegistry(runtime); // This is the exact expression agent-runner reads (`ctx.modelRegistry.runtime`). expect((facade as { runtime?: unknown }).runtime).toBe(runtime); }); });