284 lines · 10.4 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:offimport * 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, string>): 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<A, E, R>(  env: Record<string, string>,  effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,) {  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<string, unknown> }> {  return NodeFS.readFileSync(filePath, "utf8")    .trim()    .split("\n")    .filter((line) => line.length > 0)    .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });} 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<Record<string, unknown>>            | 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);        }),    ),  );});