From 66f0ea44ce278d1e13b4008c10b5c3be1077578d Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 20:39:43 +0200 Subject: [PATCH] test(sessions): cover immutable provider baseline --- .../globalProviderPolicy.acceptance.test.ts | 537 +++++++++++------- 1 file changed, 330 insertions(+), 207 deletions(-) diff --git a/src/server/sessions/globalProviderPolicy.acceptance.test.ts b/src/server/sessions/globalProviderPolicy.acceptance.test.ts index 52af212..ecd2ee8 100644 --- a/src/server/sessions/globalProviderPolicy.acceptance.test.ts +++ b/src/server/sessions/globalProviderPolicy.acceptance.test.ts @@ -4,29 +4,159 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; -import { installGlobalProviderPolicy, learnGlobalExtensionProviderIds, providerRejectionMessage } from "./globalProviderPolicy.js"; +import { + bootstrapAndFreezeGlobalExtensionProviders, + type GlobalProviderBootstrapLogger, +} from "./globalProviderPolicy.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; -import { PiSessionService } from "./piSessionService.js"; -import { CapturingSessionEventHub, createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js"; +import { PiSessionService, type PiSessionRef } from "./piSessionService.js"; +import { + CapturingSessionEventHub, + createTestModelRuntime, + TEST_MODEL_ID, + TEST_MODEL_PROVIDER, +} from "./piSessionService.testSupport.js"; /** - * Acceptance tests for the global provider policy, wired exactly as sessiond - * wires it in production: one shared ModelRuntime per daemon, the global - * extensions' provider ids learned at startup, the policy shim installed on - * the runtime, and rejections fed into the session service. Sessions are - * created through the real default runtime factory, so extensions in a temp - * cwd or temp agent dir are genuinely loaded by Pi's - * `createAgentSessionServices`. + * Acceptance coverage for the exact sessiond lifecycle: global extensions are + * loaded once against the shared ModelRuntime, provider mutations are frozen, + * and real sessions subsequently load both global and project extensions + * through Pi's public session factories. * - * These tests are also the tripwire for the shim's one piece of machinery - * (instance-method shadowing of `registerProvider` / `registerNativeProvider` - * / `unregisterProvider`): if a Pi upgrade changes how registrations reach - * the runtime, the load-time and late-registration tests here fail loudly. + * These tests are also a tripwire for the instance-method shadowing used to + * freeze `registerProvider`, native registration, and unregistration. If Pi + * changes how real extension calls reach ModelRuntime, these scenarios fail. */ +interface LogEntry { + level: "error" | "info" | "warn"; + details: Record; + message: string; +} + +interface PolicyHarness { + service: PiSessionService; + runtime: ModelRuntime; + agentDir: string; + logEntries: LogEntry[]; +} + const tempDirs: string[] = []; const services: PiSessionService[] = []; +const IGNORED_MUTATION_MESSAGE = "ignored provider mutation after global bootstrap"; + +function modelId(providerId: string, variant: string): string { + return `${providerId}-${variant}-model`; +} + +function providerBaseUrl(providerId: string, variant: string): string { + return `https://${providerId}-${variant}.example.com`; +} + +function providerConfig(providerId: string, variant = "baseline"): Record { + return { + name: `${providerId} ${variant}`, + baseUrl: providerBaseUrl(providerId, variant), + apiKey: `sk-${providerId}-${variant}-secret`, + api: "openai-completions", + models: [{ + id: modelId(providerId, variant), + name: `${providerId} ${variant} model`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000, + maxTokens: 100, + }], + }; +} + +function providerRegistrationSource(providerId: string, variant = "baseline"): string { + return `pi.registerProvider(${JSON.stringify(providerId)}, ${JSON.stringify(providerConfig(providerId, variant))});`; +} + +function nativeProviderRegistrationSource(providerId: string, variant = "baseline"): string { + const baseUrl = providerBaseUrl(providerId, variant); + const model = { + id: modelId(providerId, variant), + name: `${providerId} ${variant} model`, + api: "openai-completions", + provider: providerId, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 2_000, + maxTokens: 200, + }; + return `pi.registerProvider({ + id: ${JSON.stringify(providerId)}, + name: ${JSON.stringify(`${providerId} ${variant}`)}, + baseUrl: ${JSON.stringify(baseUrl)}, + auth: { + apiKey: { + name: ${JSON.stringify(`${providerId} API key`)}, + async resolve() { + return { + auth: { apiKey: ${JSON.stringify(`sk-${providerId}-${variant}-secret`)} }, + source: "acceptance fixture" + }; + } + } + }, + getModels() { return [${JSON.stringify(model)}]; }, + stream() { throw new Error("stream should not be called in this acceptance test"); }, + streamSimple() { throw new Error("streamSimple should not be called in this acceptance test"); } + });`; +} + +function globalProvidersSource(): string { + return ` + export default function (pi) { + ${providerRegistrationSource("global-config")} + ${nativeProviderRegistrationSource("global-native")} + } + `; +} + +function capturingLogger(): { entries: LogEntry[]; logger: GlobalProviderBootstrapLogger } { + const entries: LogEntry[] = []; + const record = (level: LogEntry["level"], details: Record, message: string): void => { + entries.push({ level, details, message }); + }; + return { + entries, + logger: { + error: (details, message) => { record("error", details, message); }, + info: (details, message) => { record("info", details, message); }, + warn: (details, message) => { record("warn", details, message); }, + }, + }; +} + +function ignoredMutationEntries(entries: readonly LogEntry[]): LogEntry[] { + return entries.filter((entry) => entry.message === IGNORED_MUTATION_MESSAGE); +} + +function expectIgnoredMutations( + entries: readonly LogEntry[], + expected: readonly { operation: string; providerId: string }[], +): void { + const ignored = ignoredMutationEntries(entries); + expect(ignored).toHaveLength(expected.length); + expect(ignored.map((entry) => entry.details)).toEqual(expect.arrayContaining( + expected.map(({ operation, providerId }) => ({ + context: "global-provider-bootstrap", + operation, + providerId, + })), + )); + expect(ignored.every((entry) => entry.level === "info")).toBe(true); + const operationProviderKeys = ignored.map((entry) => `${String(entry.details["operation"])}:${String(entry.details["providerId"])}`); + expect(new Set(operationProviderKeys).size).toBe(ignored.length); +} + afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(services.splice(0).map(async (service) => service.dispose())); @@ -39,37 +169,14 @@ async function tempDir(prefix: string): Promise { return dir; } -interface PolicyHarness { - service: PiSessionService; - runtime: ModelRuntime; - agentDir: string; -} - -async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: string } = {}): Promise { - const agentDir = options.agentDir ?? await tempDir("pi-web-policy-agent-"); - // Isolate Pi's per-user resource discovery (~/.agents/skills et al.) so the - // only extensions loaded are the ones a test writes into its temp cwd. - vi.stubEnv("HOME", await tempDir("pi-web-policy-home-")); - const runtime = options.runtime ?? await createTestModelRuntime(); - const service = new PiSessionService(new CapturingSessionEventHub(), { - agentDir, - modelRuntime: runtime, - sessionManager: createPiSessionManagerGateway({ agentDir, env: {}, sessionDirEnvKeys: [] }), - heartbeatIntervalMs: 60_000, - }); - services.push(service); - // The exact sessiond wiring: learn the agent dir's global-extension - // providers first, then the policy on the shared runtime, rejections to the - // service. - const allowedProviderIds = await learnGlobalExtensionProviderIds(runtime, agentDir); - installGlobalProviderPolicy(runtime, allowedProviderIds, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); - return { service, runtime, agentDir }; -} - -/** Write a global extension into `/extensions/` (agent-dir extensions load for every session). */ -async function agentDirWithExtension(agentDir: string, source: string): Promise { +async function writeAgentExtension(agentDir: string, source: string): Promise { await mkdir(join(agentDir, "extensions"), { recursive: true }); await writeFile(join(agentDir, "extensions", "global-probe.js"), source); +} + +async function agentDirWithExtension(source: string): Promise { + const agentDir = await tempDir("pi-web-policy-agent-"); + await writeAgentExtension(agentDir, source); return agentDir; } @@ -81,17 +188,31 @@ async function projectWithExtension(source: string): Promise { return cwd; } -function providerConfig(providerId: string): Record { - return { - baseUrl: `https://${providerId}.example.com`, - apiKey: "sk-test", - api: "openai-completions", - models: [{ id: "model-1", name: "Model One", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 }], - }; +async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: string } = {}): Promise { + const agentDir = options.agentDir ?? await tempDir("pi-web-policy-agent-"); + // Isolate Pi's per-user resource discovery (~/.agents/skills et al.) so the + // harness sees only extensions written into its explicit agent/project dirs. + vi.stubEnv("HOME", await tempDir("pi-web-policy-home-")); + const runtime = options.runtime ?? await createTestModelRuntime(); + const { entries, logger } = capturingLogger(); + + await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, logger); + + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir, + modelRuntime: runtime, + sessionManager: createPiSessionManagerGateway({ agentDir, env: {}, sessionDirEnvKeys: [] }), + heartbeatIntervalMs: 60_000, + logger, + }); + services.push(service); + return { service, runtime, agentDir, logEntries: entries }; } -function providerConfigJson(providerId: string): string { - return JSON.stringify(providerConfig(providerId)); +async function expectNoProviderMutationFeedback(service: PiSessionService, ref: PiSessionRef): Promise { + const status = await service.status(ref); + expect(status.warnings ?? []).toEqual([]); + expect(service.notificationInbox(ref).notifications).toEqual([]); } /** Parse the session-start marker file without type assertions. */ @@ -105,32 +226,84 @@ function parseToolMarker(raw: string): { activeTools: string[]; allTools: string return { activeTools: activeTools.map(String), allTools: allTools.map(String) }; } -function providerRegistrationSource(providerId: string): string { - return `pi.registerProvider(${JSON.stringify(providerId)}, ${providerConfigJson(providerId)});`; -} +describe("immutable global provider bootstrap acceptance", () => { + it("loads global config and native providers once, then treats normal session replay as a no-op", async () => { + const agentDir = await agentDirWithExtension(globalProvidersSource()); + const { service, runtime, logEntries } = await policyHarness({ agentDir }); + const baselineConfig = runtime.getRegisteredProviderConfig("global-config"); + const baselineNative = runtime.getRegisteredNativeProvider("global-native"); -const POLICY_WORDING = "PI WEB providers must come from global configuration"; + expect(baselineConfig).toMatchObject({ baseUrl: providerBaseUrl("global-config", "baseline") }); + expect(baselineNative).toMatchObject({ + id: "global-native", + baseUrl: providerBaseUrl("global-native", "baseline"), + }); + expect(logEntries).toContainEqual({ + level: "info", + details: { context: "global-provider-bootstrap", providerIds: ["global-config", "global-native"] }, + message: "global extension provider baseline bootstrapped and frozen", + }); -describe("global provider policy acceptance", () => { - it("rejects a load-time provider registration while the extension's tool and command keep working", async () => { - const { service, runtime } = await policyHarness(); + const cwd = await tempDir("pi-web-policy-project-"); + const session = await service.start(cwd); + const ref = { id: session.id, cwd }; + + expect(runtime.getRegisteredProviderIds()).toEqual(["global-config", "global-native"]); + expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig); + expect(runtime.getRegisteredNativeProvider("global-native")).toBe(baselineNative); + expect(runtime.getModel("global-config", modelId("global-config", "baseline"))).toMatchObject({ + provider: "global-config", + baseUrl: providerBaseUrl("global-config", "baseline"), + }); + expect(runtime.getModel("global-native", modelId("global-native", "baseline"))).toMatchObject({ + provider: "global-native", + baseUrl: providerBaseUrl("global-native", "baseline"), + }); + const available = await service.availableModels(ref); + expect(available).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "global-config", id: modelId("global-config", "baseline") }), + expect.objectContaining({ provider: "global-native", id: modelId("global-native", "baseline") }), + ])); + expectIgnoredMutations(logEntries, [ + { operation: "registerProvider", providerId: "global-config" }, + { operation: "registerNativeProvider", providerId: "global-native" }, + ]); + await expectNoProviderMutationFeedback(service, ref); + }); + + it("blocks real project add, replacement, and unregister calls without disabling other extension features", async () => { + const agentDir = await agentDirWithExtension(globalProvidersSource()); + const { service, runtime, logEntries } = await policyHarness({ agentDir }); + const baselineConfig = runtime.getRegisteredProviderConfig("global-config"); + const baselineNative = runtime.getRegisteredNativeProvider("global-native"); const markerPath = join(await tempDir("pi-web-policy-marker-"), "session-start.json"); const cwd = await projectWithExtension(` import { writeFileSync } from "node:fs"; export default function (pi) { - ${providerRegistrationSource("acme-ext")} + ${providerRegistrationSource("project-config", "project-secret")} + ${providerRegistrationSource("global-config", "project-secret")} + ${nativeProviderRegistrationSource("project-native", "project-secret")} + ${nativeProviderRegistrationSource("global-native", "project-secret")} pi.registerTool({ - name: "acme_tool", - label: "Acme Tool", - description: "acceptance probe tool", + name: "project_probe_tool", + label: "Project Probe Tool", + description: "non-provider acceptance probe", parameters: { type: "object", properties: {} }, - async execute() { return { content: [{ type: "text", text: "acme ok" }] }; }, + async execute() { return { content: [{ type: "text", text: "project probe ok" }] }; } }); - pi.registerCommand("acme-cmd", { description: "acceptance probe command", async handler() {} }); - pi.on("session_start", async () => { + pi.registerCommand("project-probe", { + description: "non-provider acceptance probe", + async handler() {} + }); + pi.on("session_start", () => { + ${providerRegistrationSource("project-config", "late-secret")} + pi.unregisterProvider("global-config"); + pi.unregisterProvider("global-config"); + pi.unregisterProvider("global-native"); + pi.unregisterProvider("global-native"); writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ activeTools: pi.getActiveTools(), - allTools: pi.getAllTools().map((tool) => tool.name), + allTools: pi.getAllTools().map((tool) => tool.name) })); }); } @@ -139,167 +312,118 @@ describe("global provider policy acceptance", () => { const session = await service.start(cwd); const ref = { id: session.id, cwd }; - // The session opens and the rejection is surfaced as the session's one warning. - const status = await service.status(ref); - expect(status.warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("acme-ext", cwd), source: "runtime" }, - ]); - - // The provider never reached the shared runtime or the model listings. - expect(runtime.getRegisteredProviderIds()).toEqual([]); - expect(runtime.getModel("acme-ext", "model-1")).toBeUndefined(); - const models = await service.availableModels(ref); - expect(models.some((model) => model.provider === "acme-ext")).toBe(false); - - // Global (built-in) providers are untouched. + expect(runtime.getRegisteredProviderIds()).toEqual(["global-config", "global-native"]); + expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig); + expect(runtime.getRegisteredNativeProvider("global-native")).toBe(baselineNative); + expect(runtime.getRegisteredProviderConfig("project-config")).toBeUndefined(); + expect(runtime.getRegisteredNativeProvider("project-native")).toBeUndefined(); + expect(runtime.getModel("global-config", modelId("global-config", "baseline"))).toBeDefined(); + expect(runtime.getModel("global-config", modelId("global-config", "project-secret"))).toBeUndefined(); + expect(runtime.getModel("global-native", modelId("global-native", "baseline"))).toBeDefined(); + expect(runtime.getModel("global-native", modelId("global-native", "project-secret"))).toBeUndefined(); expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBeDefined(); - // Everything else the extension registered still works. - const commands = await service.commands(ref); - expect(commands).toContainEqual({ name: "acme-cmd", description: "acceptance probe command", source: "extension" }); + expect(await service.commands(ref)).toContainEqual({ + name: "project-probe", + description: "non-provider acceptance probe", + source: "extension", + }); const marker = parseToolMarker(await readFile(markerPath, "utf-8")); - expect(marker.activeTools).toContain("acme_tool"); - }); + expect(marker.activeTools).toContain("project_probe_tool"); + expect(marker.allTools).toContain("project_probe_tool"); - it("appends exactly one warning diagnostic per rejected provider", async () => { - const { service } = await policyHarness(); - const cwd = await projectWithExtension(` - export default function (pi) { - ${providerRegistrationSource("multi-a")} - ${providerRegistrationSource("multi-b")} - ${providerRegistrationSource("multi-a")} - } - `); - - const session = await service.start(cwd); - const status = await service.status({ id: session.id, cwd }); - - expect(status.warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("multi-a", cwd), source: "runtime" }, - { severity: "warning", message: providerRejectionMessage("multi-b", cwd), source: "runtime" }, + expectIgnoredMutations(logEntries, [ + { operation: "registerProvider", providerId: "global-config" }, + { operation: "registerProvider", providerId: "project-config" }, + { operation: "registerNativeProvider", providerId: "global-native" }, + { operation: "registerNativeProvider", providerId: "project-native" }, + { operation: "unregisterProvider", providerId: "global-config" }, + { operation: "unregisterProvider", providerId: "global-native" }, ]); + expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("secret"); + expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("example.com"); + await expectNoProviderMutationFeedback(service, ref); }); - it("adds no policy warning when a load registers no providers", async () => { - const { service } = await policyHarness(); - const cwd = await tempDir("pi-web-policy-project-"); - - const session = await service.start(cwd); - const status = await service.status({ id: session.id, cwd }); - - expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); - }); - - it("rejects a late registration from a session event handler and notifies active sessions", async () => { - const { service, runtime } = await policyHarness(); - const plainCwd = await tempDir("pi-web-policy-project-"); - const listenerCwd = await projectWithExtension(` + it("keeps a tensorX-style startup provider while ignoring its session_start refresh", async () => { + const providerId = "tensorx-style"; + const agentDir = await agentDirWithExtension(` export default function (pi) { + ${providerRegistrationSource(providerId, "startup")} pi.on("session_start", () => { - ${providerRegistrationSource("late-acme")} + ${providerRegistrationSource(providerId, "late-refresh-secret")} }); } `); - - // The listener session's `session_start` fires while it is being bound, - // after the load-time rejection window has closed: a late registration. - const bystander = await service.start(plainCwd); - await service.start(listenerCwd); - - // The rejection is broadcast to the sessions active at the time. - const inbox = service.notificationInbox({ id: bystander.id, cwd: plainCwd }); - const notices = inbox.notifications.filter((notification) => notification.message.includes(POLICY_WORDING)); - expect(notices).toHaveLength(1); - expect(notices[0]).toMatchObject({ severity: "warning", message: providerRejectionMessage("late-acme") }); - - // The late registration never reached the shared runtime either. - expect(runtime.getRegisteredProviderIds()).toEqual([]); - expect(runtime.getModel("late-acme", "model-1")).toBeUndefined(); - }); - - it("keeps workspaces with colliding provider ids from affecting each other", async () => { - const { service, runtime } = await policyHarness(); - const collisionSource = ` - export default function (pi) { - ${providerRegistrationSource("collide-acme")} - } - `; - const cwdA = await projectWithExtension(collisionSource); - const cwdB = await projectWithExtension(collisionSource); - - // The pre-#76 scenario: two workspaces register the same provider id on the - // shared runtime. With the policy, both sessions open and neither - // registration exists, so there is nothing left to collide. - const sessionA = await service.start(cwdA); - const sessionB = await service.start(cwdB); - - expect((await service.status({ id: sessionA.id, cwd: cwdA })).warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("collide-acme", cwdA), source: "runtime" }, - ]); - expect((await service.status({ id: sessionB.id, cwd: cwdB })).warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("collide-acme", cwdB), source: "runtime" }, - ]); - expect(runtime.getRegisteredProviderIds()).toEqual([]); - expect(runtime.getModel("collide-acme", "model-1")).toBeUndefined(); - }); - - it("allows providers from global (agent-dir) extensions while still rejecting project extensions", async () => { - const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` - export default function (pi) { - ${providerRegistrationSource("global-ext")} - } - `); - const { service, runtime } = await policyHarness({ agentDir }); - const cwd = await projectWithExtension(` - export default function (pi) { - ${providerRegistrationSource("project-ext")} - } - `); + const { service, runtime, logEntries } = await policyHarness({ agentDir }); + const baseline = runtime.getRegisteredProviderConfig(providerId); + const cwd = await tempDir("pi-web-policy-project-"); const session = await service.start(cwd); const ref = { id: session.id, cwd }; - // The global extension's provider reached the shared runtime at daemon - // startup and stays usable; the project extension's is rejected. - expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); - expect(runtime.getModel("project-ext", "model-1")).toBeUndefined(); - expect(runtime.getRegisteredProviderIds()).toEqual(["global-ext"]); - const status = await service.status(ref); - expect(status.warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("project-ext", cwd), source: "runtime" }, + expect(runtime.getRegisteredProviderConfig(providerId)).toBe(baseline); + expect(runtime.getRegisteredProviderConfig(providerId)).toMatchObject({ + baseUrl: providerBaseUrl(providerId, "startup"), + }); + expect(runtime.getModel(providerId, modelId(providerId, "startup"))).toBeDefined(); + expect(runtime.getModel(providerId, modelId(providerId, "late-refresh-secret"))).toBeUndefined(); + expectIgnoredMutations(logEntries, [ + { operation: "registerProvider", providerId }, ]); - const models = await service.availableModels(ref); - expect(models.some((model) => model.provider === "global-ext")).toBe(true); - expect(models.some((model) => model.provider === "project-ext")).toBe(false); + expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("late-refresh-secret"); + await expectNoProviderMutationFeedback(service, ref); }); - it("lets a global extension re-register its provider late without warnings", async () => { - // The pi-tensorx pattern: register on load, then re-register from - // session_start with a refreshed model catalog. Both calls carry the - // learned id, so neither is a leak. - const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` + it("requires a fresh daemon bootstrap for global extension changes instead of applying them on reload", async () => { + const providerId = "reload-global"; + const variantEnv = "PI_WEB_ACCEPTANCE_PROVIDER_VARIANT"; + vi.stubEnv(variantEnv, "first"); + const agentDir = await agentDirWithExtension(` export default function (pi) { - ${providerRegistrationSource("global-ext")} - pi.on("session_start", () => { - ${providerRegistrationSource("global-ext")} + const variant = process.env[${JSON.stringify(variantEnv)}] ?? "missing"; + pi.registerProvider(${JSON.stringify(providerId)}, { + name: "reload global " + variant, + baseUrl: "https://reload-" + variant + ".example.com", + apiKey: "sk-reload-" + variant, + api: "openai-completions", + models: [{ + id: "model-" + variant, + name: "Reload " + variant, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100 + }] }); } `); - const { service, runtime } = await policyHarness({ agentDir }); - const plainCwd = await tempDir("pi-web-policy-project-"); + const firstDaemon = await policyHarness({ agentDir }); + const firstBaseline = firstDaemon.runtime.getRegisteredProviderConfig(providerId); + const cwd = await tempDir("pi-web-policy-project-"); + const session = await firstDaemon.service.start(cwd); + const ref = { id: session.id, cwd }; - const bystander = await service.start(plainCwd); - const status = await service.status({ id: bystander.id, cwd: plainCwd }); - expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); - const inbox = service.notificationInbox({ id: bystander.id, cwd: plainCwd }); - expect(inbox.notifications.filter((notification) => notification.message.includes(POLICY_WORDING))).toEqual([]); - expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); + vi.stubEnv(variantEnv, "second"); + await expect(firstDaemon.service.runCommand(ref, "/reload")).resolves.toMatchObject({ type: "done" }); + + expect(firstDaemon.runtime.getRegisteredProviderConfig(providerId)).toBe(firstBaseline); + expect(firstDaemon.runtime.getRegisteredProviderConfig(providerId)).toMatchObject({ + baseUrl: "https://reload-first.example.com", + }); + expect(firstDaemon.runtime.getModel(providerId, "model-first")).toBeDefined(); + expect(firstDaemon.runtime.getModel(providerId, "model-second")).toBeUndefined(); + + const secondDaemon = await policyHarness({ agentDir }); + expect(secondDaemon.runtime.getRegisteredProviderConfig(providerId)).toMatchObject({ + baseUrl: "https://reload-second.example.com", + }); + expect(secondDaemon.runtime.getModel(providerId, "model-first")).toBeUndefined(); + expect(secondDaemon.runtime.getModel(providerId, "model-second")).toBeDefined(); }); - it("does not let a project-level models.json alter the shared runtime's provider set", async () => { - // Spike assertion for plan ยง2: the shared runtime reads providers from the - // agent-dir models.json only. A project-level models.json is not a - // scoped-provider vector. + it("leaves project-level models.json behavior unchanged", async () => { const agentDir = await tempDir("pi-web-policy-agent-"); await writeFile(join(agentDir, "models.json"), JSON.stringify({ providers: { "global-acme": providerConfig("global-acme") }, @@ -317,12 +441,11 @@ describe("global provider policy acceptance", () => { })); const session = await service.start(cwd); + const ref = { id: session.id, cwd }; - // The globally configured provider is honored; the project-level one is not. - expect(runtime.getModel("global-acme", "model-1")).toBeDefined(); - expect(runtime.getModel("project-acme", "model-1")).toBeUndefined(); + expect(runtime.getModel("global-acme", modelId("global-acme", "baseline"))).toBeDefined(); + expect(runtime.getModel("project-acme", modelId("project-acme", "baseline"))).toBeUndefined(); expect(runtime.getRegisteredProviderIds()).toEqual([]); - const status = await service.status({ id: session.id, cwd }); - expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); + await expectNoProviderMutationFeedback(service, ref); }); });