1import { assert, describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3
4import {
5 T3_MCP_BEARER_ENV,
6 T3_MCP_URL_ENV,
7 T3_PI_RUNTIME_MODE_ENV,
8 T3_PI_MCP_EXTENSION_PATH_ENV,
9} from "./mcpExtensionSource.ts";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Option from "effect/Option";
13import * as NodePath from "effect/Path";
14import * as NodeFS from "effect/FileSystem";
15import * as NodeServices from "@effect/platform-node/NodeServices";
16import {
17 buildPiRpcLaunch,
18 materializePiT3McpExtension,
19 resolvePiLaunchArgs,
20} from "./mcpInjection.ts";
21import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts";
22
23const threadId = ThreadId.make("thread-pi-t3-mcp");
24
25const mcpSession = {
26 environmentId: EnvironmentId.make("environment-pi-t3-mcp"),
27 threadId,
28 providerSessionId: "mcp-session-pi",
29 providerInstanceId: ProviderInstanceId.make("pi"),
30 endpoint: "http://127.0.0.1:43123/mcp",
31 authorizationHeader: "Bearer secret-pi-token",
32 browserToolsAvailable: true,
33};
34
35describe("pi T3 MCP injection", () => {
36 it("always adds the permission bridge and configures MCP when available", () => {
37 const resolvedArgs = resolvePiLaunchArgs(
38 "--extension=/home/user/.pi/agent/extensions/demo.ts --session-dir=/tmp/pi-sessions --provider=anthropic --model=claude-sonnet --tools='' --name=-review --extension-flag=kept",
39 );
40 assert.isTrue(resolvedArgs.ok);
41 if (!resolvedArgs.ok) return;
42 const launch = buildPiRpcLaunch({
43 launchArgs: resolvedArgs.args,
44 environment: { PATH: "/usr/bin" },
45 mcpSession,
46 extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts",
47 runtimeMode: "approval-required",
48 });
49 assert.deepEqual(launch.args, [
50 "--mode",
51 "rpc",
52 "--extension",
53 "/home/user/.pi/agent/extensions/demo.ts",
54 "--session-dir",
55 "/tmp/pi-sessions",
56 "--provider",
57 "anthropic",
58 "--model",
59 "claude-sonnet",
60 "--tools",
61 "",
62 "--name",
63 "-review",
64 "--extension-flag=kept",
65 "--extension",
66 "/tmp/cache/pi-t3-mcp-extension.ts",
67 ]);
68 assert.notInclude(launch.args, "--no-extensions");
69 assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp");
70 assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token");
71 assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required");
72 assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts");
73
74 const permissionOnly = buildPiRpcLaunch({
75 launchArgs: [],
76 environment: {
77 [T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale",
78 [T3_MCP_BEARER_ENV]: "stale-token",
79 [T3_PI_MCP_EXTENSION_PATH_ENV]: "/stale/extension.ts",
80 },
81 mcpSession: undefined,
82 extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts",
83 runtimeMode: "auto-accept-edits",
84 });
85 assert.deepEqual(permissionOnly.args, [
86 "--mode",
87 "rpc",
88 "--extension",
89 "/tmp/cache/pi-t3-mcp-extension.ts",
90 ]);
91 assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]);
92 assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]);
93 assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits");
94 assert.equal(
95 permissionOnly.env[T3_PI_MCP_EXTENSION_PATH_ENV],
96 "/tmp/cache/pi-t3-mcp-extension.ts",
97 );
98 });
99
100 it("forces tools and user extensions off for unattended text generation", () => {
101 const launch = buildPiRpcLaunch({
102 launchArgs: [
103 "--tools",
104 "read,write",
105 "--extension",
106 "/home/user/.pi/agent/extensions/demo.ts",
107 "--extension=./second.ts",
108 "--provider",
109 "anthropic",
110 ],
111 environment: {},
112 mcpSession,
113 extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts",
114 sessionPath: "/fake/persisted.jsonl",
115 ephemeral: true,
116 shortLived: true,
117 disableExtensions: true,
118 disableTools: true,
119 });
120 assert.deepEqual(launch.args, [
121 "--mode",
122 "rpc",
123 "--no-session",
124 "--provider",
125 "anthropic",
126 "--offline",
127 "--no-extensions",
128 "--no-tools",
129 ]);
130 const alreadyOffline = buildPiRpcLaunch({
131 launchArgs: ["--offline"],
132 environment: {},
133 mcpSession: undefined,
134 extensionPath: undefined,
135 ephemeral: true,
136 shortLived: true,
137 });
138 assert.deepEqual(alreadyOffline.args, ["--mode", "rpc", "--no-session", "--offline"]);
139 assert.deepInclude(resolvePiLaunchArgs("--mode text"), {
140 ok: false,
141 message: "Pi launch argument '--mode' is controlled by T3 Code and cannot be overridden.",
142 });
143 assert.deepInclude(resolvePiLaunchArgs("--session old.jsonl"), { ok: false });
144 assert.deepInclude(resolvePiLaunchArgs("prompt pi immediately"), { ok: false });
145 assert.deepInclude(resolvePiLaunchArgs("--plan @instructions.md"), { ok: false });
146 });
147
148 it("rejects --provider without --model, which Pi 1.0 refuses at startup", () => {
149 const rejection = {
150 ok: false,
151 message: "Pi launch argument '--provider' requires '--model'.",
152 };
153 assert.deepInclude(resolvePiLaunchArgs("--provider openrouter"), rejection);
154 assert.deepInclude(resolvePiLaunchArgs("--provider=openrouter --models gpt-6"), rejection);
155 assert.isTrue(resolvePiLaunchArgs("--provider openrouter --model=deepseek/v4").ok);
156 assert.isTrue(resolvePiLaunchArgs("--model deepseek/v4").ok);
157 });
158});
159
160describe("materializePiT3McpExtension", () => {
161 // A pi launch concurrent with the materialize reads the extension file from
162 // disk; a truncate-then-write exposes an empty or partial file mid-write and
163 // the launch loads a broken extension.
164 it.live("never exposes a torn extension file to a concurrent reader", () =>
165 Effect.gen(function* () {
166 const fs = yield* NodeFS.FileSystem;
167 const path = yield* NodePath.Path;
168 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-atomic-" });
169 const anomalies: Array<string> = [];
170 const stopAfter = 120;
171 const writer = yield* Effect.forkChild(
172 Effect.gen(function* () {
173 for (let index = 0; index < stopAfter; index += 1) {
174 yield* fs
175 .remove(path.join(directory, "pi-t3-mcp-extension.ts"), { force: true })
176 .pipe(Effect.ignore);
177 yield* Effect.all(
178 [materializePiT3McpExtension(directory), materializePiT3McpExtension(directory)],
179 { concurrency: "unbounded" },
180 );
181 }
182 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
183 );
184 const reader = yield* Effect.forkChild(
185 Effect.forever(
186 Effect.gen(function* () {
187 const text = yield* fs
188 .readFileString(path.join(directory, "pi-t3-mcp-extension.ts"))
189 .pipe(Effect.option);
190 if (Option.isNone(text)) return; // Missing between writes: legitimate.
191 // A read that SUCCEEDS must return the full extension: an empty or
192 // partial read means the writer truncated the file mid-write.
193 if (text.value.length === 0) {
194 anomalies.push("empty: truncated mid-write");
195 } else if (text.value.length !== PI_T3_MCP_EXTENSION_SOURCE.length) {
196 anomalies.push(
197 `partial: ${text.value.length} of ${PI_T3_MCP_EXTENSION_SOURCE.length} chars`,
198 );
199 }
200 }),
201 ),
202 );
203 yield* Fiber.join(writer);
204 yield* Fiber.interrupt(reader);
205 expect(anomalies).toEqual([]);
206 }).pipe(Effect.provide(NodeServices.layer)),
207 );
208});