// @effect-diagnostics nodeBuiltinImport:off import * as NodePath from "node:path"; import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; import { FxSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeFxTextGeneration } from "./FxTextGeneration.ts"; const decodeFxSettings = Schema.decodeSync(FxSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } const FxTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-fx-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpFxWrapper(dir: string, env: Record): string { const binDir = NodePath.join(dir, "bin"); const fxPath = NodePath.join(binDir, "fx"); NodeFS.mkdirSync(binDir, { recursive: true }); NodeFS.writeFileSync( fxPath, [ "#!/bin/sh", ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), 'if [ "$1" != "acp" ] || [ "$#" != "1" ]; then', ' printf "%s\\n" "unexpected args: $*" >&2', " exit 11", "fi", `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`, "", ].join("\n"), "utf8", ); NodeFS.chmodSync(fxPath, 0o755); return fxPath; } function withFakeAcpFx( env: Record, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-fx-text-acp-")); yield* Effect.addFinalizer(() => Effect.sync(() => { NodeFS.rmSync(tempDir, { recursive: true, force: true }); }), ); const binaryPath = makeAcpFxWrapper(tempDir, env); const config = decodeFxSettings({ binaryPath }); const textGeneration = yield* makeFxTextGeneration(config); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } function readJsonRpcRequests( filePath: string, ): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { return NodeFS.readFileSync(filePath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) .map((line) => JSON.parse(line) as { method?: string; params?: Record }); } it.layer(FxTextGenerationTestLayer)("FxTextGeneration", (it) => { it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => { const requestLogDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-fx-text-log-")); const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); return withFakeAcpFx( { T3_ACP_REQUEST_LOG_PATH: requestLogPath, T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ subject: "Add Fx provider", body: "Wire up the ACP runtime and headless text generation path.", }), }, (textGeneration) => Effect.gen(function* () { const generated = yield* textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/fx", stagedSummary: "M apps/server/src/provider/Drivers/FxDriver.ts", stagedPatch: "diff --git a/.../FxDriver.ts b/.../FxDriver.ts", modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), }); expect(generated.subject).toBe("Add Fx provider"); expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path."); const requests = readJsonRpcRequests(requestLogPath); expect( requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, ).toMatchObject({ fs: { readTextFile: false, writeTextFile: false }, terminal: false, }); expect( requests.some( (request) => request.method === "session/set_config_option" && request.params?.configId === "model" && request.params?.value === "composer-2", ), ).toBe(true); }), ); }); it.effect("sends image attachments as ACP content blocks", () => { const requestLogDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-fx-image-log-"), ); const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); return withFakeAcpFx( { T3_ACP_REQUEST_LOG_PATH: requestLogPath, T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "fix/image-context" }), }, (textGeneration) => Effect.gen(function* () { const { attachmentsDir } = yield* ServerConfig.ServerConfig; const attachmentId = "fx-text-image"; NodeFS.mkdirSync(attachmentsDir, { recursive: true }); NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "hello"); const generated = yield* textGeneration.generateBranchName({ cwd: process.cwd(), message: "fix the screenshot regression", attachments: [ { type: "image", id: attachmentId, name: "regression.png", mimeType: "image/png", sizeBytes: 5, }, ], modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), }); expect(generated.branch).toBe("fix/image-context"); const promptRequest = readJsonRpcRequests(requestLogPath).find( (request) => request.method === "session/prompt", ); const prompt = promptRequest?.params?.prompt as | ReadonlyArray> | undefined; expect(prompt).toContainEqual({ type: "image", data: Buffer.from("hello").toString("base64"), mimeType: "image/png", }); }), ); }); it.effect("extracts the JSON object when Fx wraps it in conversational text", () => withFakeAcpFx( { T3_ACP_PROMPT_RESPONSE_TEXT: "Sure! Here's a thread title:\n\n" + JSON.stringify({ title: "Investigate failing CI" }) + "\n\nLet me know if you need anything else.", }, (textGeneration) => Effect.gen(function* () { const generated = yield* textGeneration.generateThreadTitle({ cwd: process.cwd(), message: "the lint job is red", modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), }); expect(generated.title).toBe("Investigate failing CI"); }), ), ); it.effect("surfaces ACP request failures as text generation errors", () => withFakeAcpFx( { T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }), }, (textGeneration) => Effect.gen(function* () { const error = yield* Effect.flip( textGeneration.generateBranchName({ cwd: process.cwd(), message: "wire up fx", modelSelection: createModelSelection( ProviderInstanceId.make("fx"), "missing-fx-model", ), }), ); expect(error._tag).toBe("TextGenerationError"); expect(error.detail).toContain("Fx ACP base model"); }), ), ); it.effect("fails with TextGenerationError when output is empty", () => withFakeAcpFx( { T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", }, (textGeneration) => Effect.gen(function* () { const error = yield* Effect.flip( textGeneration.generateThreadTitle({ cwd: process.cwd(), message: "anything", modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), }), ); expect(error._tag).toBe("TextGenerationError"); expect(error.detail).toMatch(/empty/i); }), ), ); it.effect("decodes a structured PR title + body", () => withFakeAcpFx( { T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ title: "feat(fx): wire up model configuration", body: "## Summary\n- Select models with ACP `session/set_config_option`.\n- Preserve raw model ids from the active fx catalog.", }), }, (textGeneration) => Effect.gen(function* () { const generated = yield* textGeneration.generatePrContent({ cwd: process.cwd(), baseBranch: "main", headBranch: "feat/fx-provider", commitSummary: "feat: add fx provider", diffSummary: "M apps/server/src/provider/Drivers/FxDriver.ts", diffPatch: "diff --git a/.../FxDriver.ts b/.../FxDriver.ts", modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), }); expect(generated.title).toBe("feat(fx): wire up model configuration"); expect(generated.body).toContain("Preserve raw model ids"); }), ), ); it.effect("fails with TextGenerationError when output is unparseable JSON", () => withFakeAcpFx( { T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", }, (textGeneration) => Effect.gen(function* () { const error = yield* Effect.flip( textGeneration.generateThreadTitle({ cwd: process.cwd(), message: "anything", modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), }), ); expect(error._tag).toBe("TextGenerationError"); expect(error.detail).toMatch(/invalid structured output/i); }), ), ); });