diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 37fa28c00e42a..3c562b461579d 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -19,6 +19,13 @@ const decodeRpcRequest = Schema.decodeSync( Schema.fromJsonString(Schema.Struct({ id: Schema.String, type: Schema.String })), ); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const STARTUP_DIALOG = `${encodeJsonLine({ + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Allow project MCP server “github”?", + options: ["Don't allow", "Allow"], +})}\n`; function processHandle(input: { readonly stdout?: string; @@ -118,6 +125,10 @@ const piDiscoverySpawner = ( readonly version?: string; readonly state?: unknown; readonly authCheck?: string | undefined; + /** Without `--no-approve`, print a project-trust dialog and exit 0 as Pi 1.x does. */ + readonly startupDialog?: boolean; + /** What `--no-approve` lists once project-local extensions are skipped. */ + readonly declinedProjectInventory?: unknown; } = {}, ) => Effect.succeed( @@ -132,13 +143,24 @@ const piDiscoverySpawner = ( ? Effect.die(`Unexpected pi ${args.join(" ")}`) : Effect.succeed(processHandle({ stdout: options.authCheck, exitCode: 1 })); } + if ( + options.startupDialog === true && + !args.includes("--no-extensions") && + !args.includes("--no-approve") + ) { + return Effect.succeed(processHandle({ stdout: STARTUP_DIALOG })); + } if (args.includes("--no-extensions") && extensionFreeInventory === exitsWithoutExtensions) { return Effect.succeed( processHandle({ stderr: 'Error: Unknown provider "demo".', exitCode: 1 }), ); } return piRpcProcess( - args.includes("--no-extensions") ? extensionFreeInventory : inventory, + args.includes("--no-extensions") + ? extensionFreeInventory + : args.includes("--no-approve") && options.declinedProjectInventory !== undefined + ? options.declinedProjectInventory + : inventory, args.includes("--no-extensions") ? onExtensionFreeModelRequest : Effect.void, options.state, ); @@ -423,6 +445,43 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect.each([ + { launchArgs: "", startupDialog: true, models: ["default", "anthropic/claude-opus"] }, + { launchArgs: "--approve", startupDialog: true, models: ["default"] }, + { + launchArgs: "", + startupDialog: false, + models: ["default", "anthropic/claude-opus", "project/local-model"], + }, + ])( + "keeps project trust unless a startup dialog blocks discovery (dialog: $startupDialog, launch args '$launchArgs')", + (row) => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { + models: [ + { provider: "anthropic", id: "claude-opus" }, + { provider: "project", id: "local-model" }, + ], + }, + undefined, + undefined, + { + startupDialog: row.startupDialog, + declinedProjectInventory: { models: [{ provider: "anthropic", id: "claude-opus" }] }, + }, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + launchArgs: row.launchArgs, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + row.models, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + // regression: #18498 a malformed model inventory must not restore stale Pi models it.effect.each([ ["missing models", {}], diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 43fa8f60c08a5..86a8e63de753c 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -8,6 +8,7 @@ import type { PiSettings } from "../settings.ts"; import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; @@ -167,6 +168,36 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( return connection; }); +// A failed response echoes Pi's own `command` field as the operation, so log only names we sent. +const PI_LOGGED_RPC_OPERATIONS = new Set([ + "spawn", + "read", + "write", + "exit", + "terminate", + "get_state", + "get_available_models", + "get_commands", +]); + +const PI_PROJECT_TRUST_ARGUMENTS = new Set(["--approve", "-a", "--no-approve", "-na"]); + +const readPiInventory = Effect.fn("PiProviderStatus.readPiInventory")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + timeoutMs: number, + cwd?: string, +) { + const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); + const stateData = yield* connection.request({ type: "get_state" }, timeoutMs); + const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs); + const commandsData = yield* connection + .request({ type: "get_commands" }, timeoutMs) + .pipe(Effect.orElseSucceed(() => undefined)); + return { stateData, modelsData, commandsData }; +}, Effect.scoped); + // Text generation runs Pi with --no-extensions, so it cannot load models that an // extension or package registers. Listing models with that launch tells them apart. const discoverPiTextGenerationModelSlugs = Effect.fn( @@ -217,12 +248,25 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function }), Effect.forkScoped, ); - const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); - const stateData = yield* connection.request({ type: "get_state" }, timeoutMs); - const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs); - const commandsData = yield* connection - .request({ type: "get_commands" }, timeoutMs) - .pipe(Effect.orElseSucceed(() => undefined)); + const { stateData, modelsData, commandsData } = yield* readPiInventory( + piSettings, + environment, + launchArgs, + timeoutMs, + cwd, + ).pipe( + // Pi's RPC mode reads stdin only after session_start, so a startup dialog (an MCP adapter asking + // to approve a project server) can never be answered and Pi exits. Retry declining project trust + // unless the user chose a trust mode, so trusted projects keep their project-local models. + Effect.catchIf( + (error) => + error._tag === "PiRpcError" && + (error.operation === "read" || error.operation === "exit") && + !launchArgs.some((arg) => PI_PROJECT_TRUST_ARGUMENTS.has(arg)), + () => + readPiInventory(piSettings, environment, [...launchArgs, "--no-approve"], timeoutMs, cwd), + ), + ); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); const textGenerationModelSlugs = yield* Fiber.join(textGenerationModelSlugsFiber); @@ -532,8 +576,12 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function Effect.exit, ); if (Exit.isFailure(discoveryExit)) { + const failure = Cause.findErrorOption(discoveryExit.cause); yield* Effect.logWarning("Pi RPC discovery failed.", { errorTag: causeErrorTag(discoveryExit.cause), + ...(Option.isSome(failure) && PI_LOGGED_RPC_OPERATIONS.has(failure.value.operation) + ? { operation: failure.value.operation } + : {}), }); return buildServerProvider({ presentation: PI_PRESENTATION,