1import { assert, it } from "@effect/vitest";
2import { ProviderInstanceId } from "@t3tools/contracts";
3import { createModelSelection } from "@t3tools/shared/model";
4import * as Cause from "effect/Cause";
5import * as Effect from "effect/Effect";
6import * as Queue from "effect/Queue";
7import * as Schema from "effect/Schema";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import { makePiTextGeneration } from "./textGeneration.ts";
14
15const decodeJsonLine = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
16const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
17
18const makeFakePi = (
19 reply: string,
20 setModelError?: string,
21 turnEvents: ReadonlyArray<Record<string, unknown>> = [],
22 models?: ReadonlyArray<Record<string, unknown>>,
23) =>
24 Effect.gen(function* () {
25 const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>();
26 const received: Array<Record<string, unknown>> = [];
27 const emit = (record: Record<string, unknown>) =>
28 Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`));
29 let buffered = "";
30 const onStdin = (chunk: Uint8Array) =>
31 Effect.gen(function* () {
32 buffered += new TextDecoder().decode(chunk);
33 const lines = buffered.split("\n");
34 buffered = lines.pop() ?? "";
35 for (const line of lines.filter((candidate) => candidate.length > 0)) {
36 const record = decodeJsonLine(line) as Record<string, unknown>;
37 received.push(record);
38 const response = { type: "response", id: record["id"], command: record["type"] };
39 if (record["type"] === "prompt") {
40 yield* emit({ ...response, success: true });
41 for (const event of turnEvents) yield* emit(event);
42 yield* emit({ type: "agent_settled" });
43 } else if (record["type"] === "get_last_assistant_text") {
44 yield* emit({ ...response, success: true, data: { text: reply } });
45 } else if (record["type"] === "get_available_models" && models !== undefined) {
46 yield* emit({ ...response, success: true, data: { models } });
47 } else if (record["type"] === "set_model" && setModelError !== undefined) {
48 yield* emit({ ...response, success: false, error: setModelError });
49 } else {
50 yield* emit({ ...response, success: true });
51 }
52 }
53 });
54 const spawner = ChildProcessSpawner.make(() =>
55 Effect.succeed(
56 ChildProcessSpawner.makeHandle({
57 // Outside the valid pid range, so PiRpc's process-group kill never lands.
58 pid: ChildProcessSpawner.ProcessId(999_999_999),
59 exitCode: Effect.never,
60 isRunning: Effect.succeed(true),
61 kill: () => Effect.void,
62 unref: Effect.succeed(Effect.void),
63 stdin: Sink.forEach(onStdin),
64 stdout: Stream.fromQueue(stdout),
65 stderr: Stream.empty,
66 all: Stream.empty,
67 getInputFd: () => Sink.drain,
68 getOutputFd: () => Stream.empty,
69 }),
70 ),
71 );
72 return { spawner, received };
73 });
74
75it.effect("runs Pi offline, since the process is stopped as soon as it answers", () =>
76 Effect.gen(function* () {
77 const pi = yield* makeFakePi(JSON.stringify({ subject: "Update README", body: "" }));
78 const launches: Array<ReadonlyArray<string>> = [];
79 const spawner = ChildProcessSpawner.make((command) => {
80 if (ChildProcess.isStandardCommand(command)) launches.push(command.args.map(String));
81 return pi.spawner.spawn(command);
82 });
83 const textGeneration = yield* makePiTextGeneration(
84 { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] },
85 {},
86 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
87
88 const result = yield* textGeneration.generateCommitMessage({
89 cwd: process.cwd(),
90 branch: "main",
91 stagedSummary: "M README.md",
92 stagedPatch: "diff --git a/README.md b/README.md",
93 modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "default"),
94 });
95
96 assert.equal(result.subject, "Update README");
97 assert.deepEqual(
98 launches.map((args) => args.filter((arg) => arg === "--offline")),
99 [["--offline"]],
100 );
101 }),
102);
103
104it.effect("names the model and the disabled extensions when Pi cannot load it", () =>
105 Effect.gen(function* () {
106 const pi = yield* makeFakePi("", "Model not found: demo/demo-model");
107 const textGeneration = yield* makePiTextGeneration(
108 { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] },
109 {},
110 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner));
111
112 const error = yield* textGeneration
113 .generateCommitMessage({
114 cwd: process.cwd(),
115 branch: "main",
116 stagedSummary: "M README.md",
117 stagedPatch: "diff --git a/README.md b/README.md",
118 modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "demo/demo-model"),
119 })
120 .pipe(Effect.flip);
121
122 assert.equal(error._tag, "TextGenerationError");
123 assert.include(error.detail, "Pi rejected model 'demo/demo-model'");
124 assert.include(error.detail, "Model not found: demo/demo-model");
125 assert.include(error.detail, "--no-extensions");
126 assert.isUndefined(pi.received.find((record) => record["type"] === "prompt"));
127 }),
128);
129
130it.effect("selects the provider and id Pi reported when the provider contains a slash", () =>
131 Effect.gen(function* () {
132 const provider = "llama-server=http://127.0.0.1:8080";
133 const pi = yield* makeFakePi(
134 JSON.stringify({ subject: "Update README", body: "" }),
135 undefined,
136 [],
137 [{ provider, id: "qwen3" }],
138 );
139 const textGeneration = yield* makePiTextGeneration(
140 { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] },
141 {},
142 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner));
143
144 yield* textGeneration.generateCommitMessage({
145 cwd: process.cwd(),
146 branch: "main",
147 stagedSummary: "M README.md",
148 stagedPatch: "diff --git a/README.md b/README.md",
149 modelSelection: createModelSelection(ProviderInstanceId.make("pi"), `${provider}/qwen3`),
150 });
151
152 const setModel = pi.received.find((record) => record["type"] === "set_model");
153 assert.deepEqual(
154 { provider: setModel?.["provider"], modelId: setModel?.["modelId"] },
155 { provider, modelId: "qwen3" },
156 );
157 }),
158);
159
160const failedTurnWith = (errorMessage: string) => ({
161 type: "message_end",
162 message: { role: "assistant", stopReason: "error", errorMessage },
163});
164
165const failedTurn = failedTurnWith(
166 '401: {"error":{"message":"Incorrect API key provided: sk-test-SECRET123"}}',
167);
168
169const generateCommitMessage = (turnEvents: ReadonlyArray<Record<string, unknown>>, reply = "") =>
170 Effect.gen(function* () {
171 const pi = yield* makeFakePi(reply, undefined, turnEvents);
172 const textGeneration = yield* makePiTextGeneration(
173 { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] },
174 {},
175 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner));
176 return yield* textGeneration.generateCommitMessage({
177 cwd: process.cwd(),
178 branch: "main",
179 stagedSummary: "M README.md",
180 stagedPatch: "diff --git a/README.md b/README.md",
181 modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "default"),
182 });
183 });
184
185it.effect("reports Pi's turn error when the reply is empty", () =>
186 Effect.gen(function* () {
187 const error = yield* generateCommitMessage([failedTurn]).pipe(Effect.flip);
188
189 assert.equal(error._tag, "TextGenerationError");
190 assert.equal(error.detail, "Pi could not generate text: the model provider returned HTTP 401.");
191 }),
192);
193
194it.effect("keeps Pi's raw error text out of the error", () =>
195 Effect.gen(function* () {
196 const error = yield* generateCommitMessage([
197 failedTurnWith("Invalid API key sk-test-SECRET123"),
198 ]).pipe(Effect.flip);
199
200 assert.equal(error.detail, "Pi could not generate text: the model provider reported an error.");
201 assert.notInclude(error.message, "SECRET123");
202 }),
203);
204
205it.effect("keeps the reply when Pi answers after a failed attempt", () =>
206 Effect.gen(function* () {
207 const result = yield* generateCommitMessage(
208 [failedTurn, { type: "message_end", message: { role: "assistant", stopReason: "stop" } }],
209 JSON.stringify({ subject: "Update README", body: "" }),
210 );
211
212 assert.equal(result.subject, "Update README");
213 }),
214);
215
216it.effect("drops an earlier error once a later attempt succeeds", () =>
217 Effect.gen(function* () {
218 const error = yield* generateCommitMessage([
219 failedTurn,
220 { type: "message_end", message: { role: "assistant", stopReason: "stop" } },
221 ]).pipe(Effect.flip);
222
223 assert.equal(error.detail, "Pi returned empty output.");
224 }),
225);