1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3import * as NodeOS from "node:os";
4import * as NodeURL from "node:url";
5import * as NodeFS from "node:fs";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import { createModelSelection } from "@t3tools/shared/model";
13import { expect } from "vite-plus/test";
14import { ProviderInstanceId } from "@t3tools/contracts";
15import { GrokSettings } from "@t3tools/provider-grok/settings";
16
17import * as ServerConfig from "../config.ts";
18import * as TextGeneration from "./TextGeneration.ts";
19import { makeGrokTextGeneration } from "@t3tools/provider-grok/testing";
20import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli";
21const decodeGrokSettings = Schema.decodeSync(GrokSettings);
22
23const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
24const mockAgentPath = NodePath.join(__dirname, "../provider/testFixtures/grok-text-mock-agent.mjs");
25
26const layerGrokTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), {
27 prefix: "t3code-grok-text-generation-test-",
28}).pipe(Layer.provideMerge(NodeServices.layer));
29
30function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string {
31 return writeFakeCli({
32 directory: NodePath.join(dir, "bin"),
33 name: "grok",
34 env,
35 source: execScriptSource({
36 scriptPath: mockAgentPath,
37 expectedArgs: ["agent", "stdio"],
38 }),
39 });
40}
41
42function withFakeAcpGrok<A, E, R>(
43 env: Record<string, string>,
44 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
45) {
46 return Effect.gen(function* () {
47 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-grok-text-acp-"));
48 yield* Effect.addFinalizer(() =>
49 Effect.sync(() => {
50 NodeFS.rmSync(tempDir, { recursive: true, force: true });
51 }),
52 );
53 const binaryPath = makeAcpGrokWrapper(tempDir, env);
54 const config = decodeGrokSettings({ binaryPath });
55 const textGeneration = yield* makeGrokTextGeneration(config);
56 return yield* effectFn(textGeneration);
57 }).pipe(Effect.scoped);
58}
59
60function readJsonRpcRequests(
61 filePath: string,
62): ReadonlyArray<{ readonly method?: string; readonly params?: Record<string, unknown> }> {
63 return NodeFS.readFileSync(filePath, "utf8")
64 .trim()
65 .split("\n")
66 .filter((line) => line.length > 0)
67 .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
68}
69
70it.layer(layerGrokTextGenerationTest)("GrokTextGeneration", (it) => {
71 it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => {
72 const requestLogDir = NodeFS.mkdtempSync(
73 NodePath.join(NodeOS.tmpdir(), "t3code-grok-text-log-"),
74 );
75 const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson");
76
77 return withFakeAcpGrok(
78 {
79 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
80 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
81 subject: "Add Grok provider",
82 body: "Wire up the ACP runtime and headless text generation path.",
83 }),
84 },
85 (textGeneration) =>
86 Effect.gen(function* () {
87 const generated = yield* textGeneration.generateCommitMessage({
88 cwd: process.cwd(),
89 branch: "feature/grok",
90 stagedSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts",
91 stagedPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts",
92 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"),
93 });
94
95 expect(generated.subject).toBe("Add Grok provider");
96 expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path.");
97
98 const requests = readJsonRpcRequests(requestLogPath);
99 expect(
100 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
101 ).toMatchObject({
102 fs: { readTextFile: false, writeTextFile: false },
103 terminal: false,
104 });
105 expect(
106 requests.some(
107 (request) =>
108 request.method === "session/set_model" &&
109 request.params?.modelId === "grok-mock-alt",
110 ),
111 ).toBe(true);
112 }),
113 );
114 });
115
116 it.effect("extracts the JSON object when Grok wraps it in conversational text", () =>
117 withFakeAcpGrok(
118 {
119 T3_ACP_PROMPT_RESPONSE_TEXT:
120 "Sure! Here's a thread title:\n\n" +
121 JSON.stringify({ title: "Investigate failing CI" }) +
122 "\n\nLet me know if you need anything else.",
123 },
124 (textGeneration) =>
125 Effect.gen(function* () {
126 const generated = yield* textGeneration.generateThreadTitle({
127 cwd: process.cwd(),
128 message: "the lint job is red",
129 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"),
130 });
131 expect(generated.title).toBe("Investigate failing CI");
132 }),
133 ),
134 );
135
136 it.effect("surfaces ACP request failures as text generation errors", () =>
137 withFakeAcpGrok(
138 {
139 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }),
140 },
141 (textGeneration) =>
142 Effect.gen(function* () {
143 const error = yield* Effect.flip(
144 textGeneration.generateBranchName({
145 cwd: process.cwd(),
146 message: "wire up grok",
147 modelSelection: createModelSelection(
148 ProviderInstanceId.make("grok"),
149 "missing-grok-model",
150 ),
151 }),
152 );
153 expect(error._tag).toBe("TextGenerationError");
154 expect(error.detail).toContain("Grok ACP base model");
155 }),
156 ),
157 );
158
159 it.effect("fails with TextGenerationError when output is empty", () =>
160 withFakeAcpGrok(
161 {
162 T3_ACP_PROMPT_RESPONSE_TEXT: " \n ",
163 },
164 (textGeneration) =>
165 Effect.gen(function* () {
166 const error = yield* Effect.flip(
167 textGeneration.generateThreadTitle({
168 cwd: process.cwd(),
169 message: "anything",
170 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"),
171 }),
172 );
173 expect(error._tag).toBe("TextGenerationError");
174 expect(error.detail).toMatch(/empty/i);
175 }),
176 ),
177 );
178
179 it.effect("decodes a structured PR title + body", () =>
180 withFakeAcpGrok(
181 {
182 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
183 title: "feat(grok): wire up session/set_model",
184 body: "## Summary\n- Replace `-m` spawn flag with the typed ACP `session/set_model`.\n- Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT` into a validation error.",
185 }),
186 },
187 (textGeneration) =>
188 Effect.gen(function* () {
189 const generated = yield* textGeneration.generatePrContent({
190 cwd: process.cwd(),
191 baseBranch: "main",
192 headBranch: "feat/grok-provider",
193 commitSummary: "feat: add grok provider",
194 diffSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts",
195 diffPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts",
196 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"),
197 });
198
199 expect(generated.title).toBe("feat(grok): wire up session/set_model");
200 expect(generated.body).toContain("Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT`");
201 }),
202 ),
203 );
204
205 it.effect("fails with TextGenerationError when output is unparseable JSON", () =>
206 withFakeAcpGrok(
207 {
208 T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model",
209 },
210 (textGeneration) =>
211 Effect.gen(function* () {
212 const error = yield* Effect.flip(
213 textGeneration.generateThreadTitle({
214 cwd: process.cwd(),
215 message: "anything",
216 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"),
217 }),
218 );
219 expect(error._tag).toBe("TextGenerationError");
220 expect(error.detail).toMatch(/invalid structured output/i);
221 }),
222 ),
223 );
224});