| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodePath from "node:path"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodeURL from "node:url"; |
| 5 | import * as NodeFS from "node:fs"; |
| 6 | |
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import { it } from "@effect/vitest"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import { createModelSelection } from "@t3tools/shared/model"; |
| 13 | import { expect } from "vite-plus/test"; |
| 14 | import { ProviderInstanceId } from "@t3tools/contracts"; |
| 15 | import { GrokSettings } from "@t3tools/provider-grok/settings"; |
| 16 | |
| 17 | import * as ServerConfig from "../config.ts"; |
| 18 | import * as TextGeneration from "./TextGeneration.ts"; |
| 19 | import { makeGrokTextGeneration } from "@t3tools/provider-grok/testing"; |
| 20 | import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; |
| 21 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 22 | |
| 23 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 24 | const mockAgentPath = NodePath.join(__dirname, "../provider/testFixtures/grok-text-mock-agent.mjs"); |
| 25 | |
| 26 | const layerGrokTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 27 | prefix: "t3code-grok-text-generation-test-", |
| 28 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 29 | |
| 30 | function 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 | |
| 42 | function 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 | |
| 60 | function 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 | |
| 70 | it.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 | }); |