1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Queue from "effect/Queue";
10import * as Schema from "effect/Schema";
11import * as Sink from "effect/Sink";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14import * as HttpClient from "effect/http/HttpClient";
15import * as ChildProcess from "effect/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17
18import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
19import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
20import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
21import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
22import type { PiSettings } from "../settings.ts";
23import { PiDriver } from "./driver.ts";
24
25const layerTest = Layer.mergeAll(
26 TestProviderHost.layer({
27 cwd: "/machine",
28 settings: { ...DEFAULT_SERVER_SETTINGS, enableProviderUpdateChecks: false },
29 runBackgroundWork: false,
30 }),
31 IdAllocator.layer,
32 McpProviderSessions.layer,
33 ScriptProgress.layerUnavailable,
34 ProviderLatestVersions.layer,
35 Layer.succeed(
36 HttpClient.HttpClient,
37 HttpClient.make(() => Effect.die("Unexpected HTTP")),
38 ),
39).pipe(Layer.provideMerge(NodeServices.layer));
40
41const decodeRequest = Schema.decodeSync(
42 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
43);
44const encoder = new TextEncoder();
45const personalSkill = {
46 name: "skill:personal",
47 source: "skill",
48 sourceInfo: { scope: "user", path: "/home/.pi/agent/skills/personal/SKILL.md" },
49};
50
51// Respond through the real stdio transport, with a distinct command catalog for each cwd.
52const makePiSpawner = Effect.fnUntraced(function* (
53 discovery: { readonly state?: unknown; readonly models?: ReadonlyArray<unknown> } = {},
54) {
55 const pendingCommand = yield* Deferred.make<void>();
56 const launches: Array<ChildProcess.StandardCommand> = [];
57 const spawner = ChildProcessSpawner.make((command) =>
58 Effect.gen(function* () {
59 assert.isTrue(ChildProcess.isStandardCommand(command));
60 if (!ChildProcess.isStandardCommand(command)) return yield* Effect.die("Unexpected pipeline");
61 launches.push(command);
62 const version = command.args.includes("--version");
63 const stdout = yield* Queue.unbounded<Uint8Array>();
64 const cwd = command.options.cwd;
65 return ChildProcessSpawner.makeHandle({
66 // Outside the valid PID range, so transport cleanup cannot signal a real process.
67 pid: ChildProcessSpawner.ProcessId(999_999_999),
68 exitCode: version ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) : Effect.never,
69 isRunning: Effect.succeed(!version),
70 kill: () => Effect.void,
71 unref: Effect.succeed(Effect.void),
72 stdin: Sink.forEach((chunk: Uint8Array) => {
73 const request = decodeRequest(new TextDecoder().decode(chunk).trim());
74 if (cwd === "/pending" && request.type === "get_commands") {
75 return Deferred.succeed(pendingCommand, undefined).pipe(Effect.asVoid);
76 }
77 const failed = cwd === "/failed" && request.type === "get_commands";
78 const data =
79 request.type === "get_commands"
80 ? {
81 commands: [
82 personalSkill,
83 ...(cwd === "/machine"
84 ? []
85 : [
86 {
87 name: `skill:${cwd?.slice(1)}`,
88 source: "skill",
89 sourceInfo: {
90 scope: "project",
91 path: `${cwd}/.agents/skills/SKILL.md`,
92 },
93 },
94 { name: `prompt-${cwd?.slice(1)}`, source: "prompt" },
95 ]),
96 ],
97 }
98 : request.type === "get_available_models"
99 ? { models: discovery.models ?? [{ provider: "test", id: "model" }] }
100 : request.type === "get_state"
101 ? (discovery.state ?? {})
102 : {};
103 return Queue.offer(
104 stdout,
105 encoder.encode(
106 `${JSON.stringify({ type: "response", id: request.id, success: !failed, data, ...(failed ? { error: "commands unavailable" } : {}) })}\n`,
107 ),
108 ).pipe(Effect.asVoid);
109 }),
110 stdout: version ? Stream.succeed(encoder.encode("pi 1.0.2\n")) : Stream.fromQueue(stdout),
111 stderr: Stream.empty,
112 all: Stream.empty,
113 getInputFd: () => Sink.drain,
114 getOutputFd: () => Stream.empty,
115 });
116 }),
117 );
118 return { spawner, launches, pendingCommand };
119});
120
121const create = (config: Partial<PiSettings> = {}, enabled = true) =>
122 PiDriver.create({
123 instanceId: ProviderInstanceId.make("pi-workspace-test"),
124 displayName: "My Pi",
125 accentColor: "#abcdef",
126 environment: [{ name: "PI_CODING_AGENT_DIR", value: "/isolated-pi", sensitive: false }],
127 enabled,
128 config: { ...PiDriver.defaultConfig(), binaryPath: "custom-pi", ...config },
129 });
130
131it.layer(layerTest)("PiDriver workspace discovery", (it) => {
132 it.effect("keeps each workspace's skills and commands separate from the machine catalog", () =>
133 Effect.gen(function* () {
134 const { spawner, launches } = yield* makePiSpawner();
135 const instance = yield* create({ launchArgs: '--approve --skill "extra skill"' }).pipe(
136 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
137 );
138 yield* instance.snapshot.refresh;
139 assert.isDefined(instance.snapshotForCwd);
140 const [first, second] = yield* Effect.all(
141 [instance.snapshotForCwd!("/first"), instance.snapshotForCwd!("/second")],
142 { concurrency: "unbounded" },
143 );
144 assert.deepEqual(
145 first.skills.map((skill) => skill.name),
146 ["personal", "first"],
147 );
148 assert.deepEqual(
149 second.skills.map((skill) => skill.name),
150 ["personal", "second"],
151 );
152 assert.deepEqual(
153 first.slashCommands.map((command) => command.name),
154 ["compact", "prompt-first"],
155 );
156 const machine = yield* instance.snapshot.getSnapshot;
157 assert.deepEqual(
158 machine.skills.map((skill) => skill.name),
159 ["personal"],
160 );
161 assert.deepEqual(
162 machine.slashCommands.map((command) => command.name),
163 ["compact"],
164 );
165 assert.equal(first.instanceId, instance.instanceId);
166 assert.equal(first.displayName, "My Pi");
167 assert.equal(first.accentColor, "#abcdef");
168 assert.deepEqual(first.models, machine.models);
169 const workspaceLaunch = launches.find((launch) => launch.options.cwd === "/first");
170 assert.isDefined(workspaceLaunch);
171 assert.equal(workspaceLaunch!.command, "custom-pi");
172 assert.includeMembers(
173 [...workspaceLaunch!.args],
174 ["--approve", "--skill", "extra skill", "--no-session"],
175 );
176 assert.equal(workspaceLaunch!.options.env?.PI_CODING_AGENT_DIR, "/isolated-pi");
177 }).pipe(Effect.scoped),
178 );
179
180 it.effect("does not run a disabled provider's workspace probe", () =>
181 Effect.gen(function* () {
182 const instance = yield* create({}, false).pipe(
183 Effect.provideService(
184 ChildProcessSpawner.ChildProcessSpawner,
185 ChildProcessSpawner.make(() => Effect.die("Disabled Pi must not spawn")),
186 ),
187 );
188 assert.isDefined(instance.snapshotForCwd);
189 const workspace = yield* instance.snapshotForCwd!("/first");
190 assert.isFalse(workspace.enabled);
191 assert.deepEqual(workspace.skills, []);
192 }).pipe(Effect.scoped),
193 );
194
195 it.effect.each([true, false])(
196 "limits discovered thinking defaults to the known active model, known=%s",
197 (known) =>
198 Effect.gen(function* () {
199 const { spawner } = yield* makePiSpawner({
200 state: {
201 thinkingLevel: "high",
202 ...(known ? { model: { provider: "test", id: "model" } } : {}),
203 },
204 models: [
205 { provider: "test", id: "model", reasoning: true },
206 { provider: "other", id: "model", reasoning: true },
207 { provider: "test", id: "other", reasoning: true },
208 ],
209 });
210 const instance = yield* create().pipe(
211 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
212 );
213 yield* instance.snapshot.refresh;
214 const snapshot = yield* instance.snapshot.getSnapshot;
215 assert.deepEqual(
216 snapshot.models.map((model) => [
217 model.slug,
218 (model.capabilities?.optionDescriptors ?? []).flatMap((descriptor) =>
219 descriptor.type === "select"
220 ? descriptor.options.filter((option) => option.isDefault).map((option) => option.id)
221 : [],
222 ),
223 ]),
224 [
225 ["default", []],
226 ["test/model", known ? ["high"] : []],
227 ["other/model", []],
228 ["test/other", []],
229 ],
230 );
231 }).pipe(Effect.scoped),
232 );
233
234 it.effect(
235 "fails command discovery instead of returning an empty successful workspace catalog",
236 () =>
237 Effect.gen(function* () {
238 const { spawner } = yield* makePiSpawner();
239 const instance = yield* create().pipe(
240 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
241 );
242 yield* instance.snapshot.refresh;
243 assert.isDefined(instance.snapshotForCwd);
244 const error = yield* Effect.flip(instance.snapshotForCwd!("/failed"));
245 assert.equal(error._tag, "ProviderDriverError");
246 assert.equal(error.instanceId, instance.instanceId);
247 assert.deepEqual(
248 (yield* instance.snapshot.getSnapshot).skills.map((skill) => skill.name),
249 ["personal"],
250 );
251 }).pipe(Effect.scoped),
252 );
253
254 it.effect("times out workspace discovery that needs interactive input", () =>
255 Effect.gen(function* () {
256 const { spawner, pendingCommand } = yield* makePiSpawner();
257 const instance = yield* create().pipe(
258 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
259 );
260 const probe = yield* instance.snapshotForCwd!("/pending").pipe(Effect.flip, Effect.forkChild);
261 yield* Deferred.await(pendingCommand);
262 yield* TestClock.adjust("15 seconds");
263 const error = yield* Fiber.join(probe);
264 assert.equal(error._tag, "ProviderDriverError");
265 assert.include(error.detail, "workspace commands");
266 }).pipe(Effect.scoped),
267 );
268});