Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion packages/provider-pi/src/server/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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,
);
Expand Down Expand Up @@ -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", {}],
Expand Down
60 changes: 54 additions & 6 deletions packages/provider-pi/src/server/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>,
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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading