History

test(server): run fake provider CLIs through a Node stub on every host (#9567)

Julius Marminge committed 2 months ago

Commit 5c6c1d61 parent 30f128f12 files · +383 −370Browse files

Unified · Split

12 files
  1. .gitattributes +3 −0
  2. apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +6 −1
  3. apps/server/src/provider/Layers/CursorAdapter.test.ts +24 −31
  4. apps/server/src/provider/Layers/CursorProvider.test.ts +25 −28
  5. apps/server/src/provider/Layers/GrokAdapter.test.ts +12 −13
  6. apps/server/src/provider/Layers/GrokProvider.test.ts +29 −32
  7. apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd +8 −0
  8. apps/server/src/testUtils/fakeCli.ts +106 −0
  9. apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +12 −30
  10. apps/server/src/textGeneration/CodexTextGeneration.test.ts +99 −156
  11. apps/server/src/textGeneration/CursorTextGeneration.test.ts +49 −56
  12. apps/server/src/textGeneration/GrokTextGeneration.test.ts +10 −23
.gitattributes+3−0
@@ -1,2 +1,5 @@
1# git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt1# git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt
2* text=auto eol=lf2* text=auto eol=lf
3# Batch files need CRLF for cmd.exe to parse them reliably.
4*.cmd text eol=crlf
5*.bat text eol=crlf
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+6−1
@@ -23,6 +23,7 @@ import { assert, describe } from "vite-plus/test"; · 22 unmodified lines
23 23
24import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" };24import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" };
25import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";25import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";
26import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
26 27
27const ROOT = wireFixture.rootThreadId;28const ROOT = wireFixture.rootThreadId;
28const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];29const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];
@@ -157,7 +158,11 @@ function readRecordedRequests() { · 128 unmodified lines
157}158}
158 159
159const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json");160const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json");
160const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh");161// Windows cannot run the shebang wrapper; the .cmd sibling does the same job.
162const peerPath = NodePath.join(
163 import.meta.dirname,
164 `../testFixtures/codexCollabMockPeer.${HostProcessPlatform.defaultValue() === "win32" ? "cmd" : "sh"}`,
165);
161 166
162describe("CodexSessionRuntime collab integration", () => {167describe("CodexSessionRuntime collab integration", () => {
163 it.effect("looks up child model metadata once after activity registration", () =>168 it.effect("looks up child model metadata once after activity registration", () =>
apps/server/src/provider/Layers/CursorAdapter.test.ts+24−31
@@ -30,6 +30,8 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; · 29 unmodified lines
30import { ServerSettingsService } from "../../serverSettings.ts";30import { ServerSettingsService } from "../../serverSettings.ts";
31import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";31import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
32import { makeCursorAdapter } from "./CursorAdapter.ts";32import { makeCursorAdapter } from "./CursorAdapter.ts";
33import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
34import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
33const decodeCursorSettings = Schema.decodeSync(CursorSettings);35const decodeCursorSettings = Schema.decodeSync(CursorSettings);
34 36
35// Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`.37// Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`.
@@ -39,26 +41,25 @@ class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>() · 3 unmodified lines
39 41
40const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));42const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
41const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");43const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
42const mockAgentCommand = "node";44// Stopping a session kills the agent with SIGTERM; Windows terminates the
43const mockAgentArgs = [mockAgentPath] as const;45// process instead, so the mock never sees a signal to log.
44 46const windowsHost = HostProcessPlatform.defaultValue() === "win32";
45async function makeMockAgentWrapper(47async function makeMockAgentWrapper(
46 extraEnv?: Record<string, string>,48 extraEnv?: Record<string, string>,
47 options?: { initialDelaySeconds?: number },49 options?: { initialDelaySeconds?: number },
48) {50) {
49 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-"));51 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-"));
50 const wrapperPath = NodePath.join(dir, "fake-agent.sh");52 return writeFakeCli({
51 const envExports = Object.entries(extraEnv ?? {})53 directory: dir,
52 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)54 name: "fake-agent",
53 .join("\n");55 env: extraEnv ?? {},
54 const script = `#!/bin/sh56 source: execScriptSource({
55${envExports}57 scriptPath: mockAgentPath,
56${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""}58 ...(options?.initialDelaySeconds === undefined
57exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@"59 ? {}
58`;60 : { delayMs: Math.round(options.initialDelaySeconds * 1000) }),
59 await NodeFSP.writeFile(wrapperPath, script, "utf8");61 }),
60 await NodeFSP.chmod(wrapperPath, 0o755);62 });
61 return wrapperPath;
62}63}
63 64
64async function makeProbeWrapper(65async function makeProbeWrapper(
@@ -67,20 +68,12 @@ async function makeProbeWrapper( · 2 unmodified lines
67 extraEnv?: Record<string, string>,68 extraEnv?: Record<string, string>,
68) {69) {
69 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-"));70 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-"));
70 const wrapperPath = NodePath.join(dir, "fake-agent.sh");71 return writeFakeCli({
71 const envExports = Object.entries(extraEnv ?? {})72 directory: dir,
72 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)73 name: "fake-agent",
73 .join("\n");74 env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv },
74 const script = `#!/bin/sh75 source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }),
75printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)}76 });
76printf '\n' >> ${JSON.stringify(argvLogPath)}
77export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)}
78${envExports}
79exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@"
80`;
81 await NodeFSP.writeFile(wrapperPath, script, "utf8");
82 await NodeFSP.chmod(wrapperPath, 0o755);
83 return wrapperPath;
84}77}
85 78
86async function readArgvLog(filePath: string) {79async function readArgvLog(filePath: string) {
@@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { · 303 unmodified lines
390 }),383 }),
391 );384 );
392 385
393 it.effect("closes the ACP child process when a session stops", () =>386 it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () =>
394 Effect.gen(function* () {387 Effect.gen(function* () {
395 const adapter = yield* CursorAdapter;388 const adapter = yield* CursorAdapter;
396 const settings = yield* ServerSettingsService;389 const settings = yield* ServerSettingsService;
@@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { · 25 unmodified lines
422 }),415 }),
423 );416 );
424 417
425 it.effect(418 it.effect.skipIf(windowsHost)(
426 "serializes concurrent startSession calls for the same thread and closes the replaced ACP session",419 "serializes concurrent startSession calls for the same thread and closes the replaced ACP session",
427 () =>420 () =>
428 Effect.gen(function* () {421 Effect.gen(function* () {
apps/server/src/provider/Layers/CursorProvider.test.ts+25−28
@@ -30,6 +30,8 @@ import { · 29 unmodified lines
30 probeCursorSkills,30 probeCursorSkills,
31 rewriteCursorSkillMentions,31 rewriteCursorSkillMentions,
32} from "../Drivers/CursorSkills.ts";32} from "../Drivers/CursorSkills.ts";
33import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
34import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
33 35
34const runNode = <A, E>(36const runNode = <A, E>(
35 effect: Effect.Effect<37 effect: Effect.Effect<
@@ -39,6 +41,10 @@ const runNode = <A, E>( · 3 unmodified lines
39 >,41 >,
40): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));42): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));
41 43
44// Closing the probe kills the agent with SIGTERM; Windows terminates the
45// process instead, so the mock never sees a signal to log.
46const windowsHost = HostProcessPlatform.defaultValue() === "win32";
47
42const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () {48const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () {
43 const path = yield* Path.Path;49 const path = yield* Path.Path;
44 return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url));50 return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url));
@@ -73,47 +79,38 @@ const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( · 28 unmodified lines
73 extraEnv?: Record<string, string>,79 extraEnv?: Record<string, string>,
74) {80) {
75 const fileSystem = yield* FileSystem.FileSystem;81 const fileSystem = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;
77 const mockAgentPath = yield* resolveMockAgentPath();82 const mockAgentPath = yield* resolveMockAgentPath();
78 const dir = yield* fileSystem.makeTempDirectory({83 const dir = yield* fileSystem.makeTempDirectory({
79 directory: NodeOS.tmpdir(),84 directory: NodeOS.tmpdir(),
80 prefix: "cursor-provider-mock-",85 prefix: "cursor-provider-mock-",
81 });86 });
82 const wrapperPath = path.join(dir, "fake-agent.sh");87 return writeFakeCli({
83 const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" ");88 directory: dir,
84 const envExports = Object.entries(extraEnv ?? {})89 name: "fake-agent",
85 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)90 env: extraEnv ?? {},
86 .join("\n");91 source: execScriptSource({ scriptPath: mockAgentPath }),
87 const script = `#!/bin/sh92 });
88${envExports}
89exec ${mockAgentCommand} "$@"
90`;
91 yield* fileSystem.writeFileString(wrapperPath, script);
92 yield* fileSystem.chmod(wrapperPath, 0o755);
93 return wrapperPath;
94});93});
95 94
96const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () {95const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () {
97 const fileSystem = yield* FileSystem.FileSystem;96 const fileSystem = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const mockAgentPath = yield* resolveMockAgentPath();97 const mockAgentPath = yield* resolveMockAgentPath();
100 const dir = yield* fileSystem.makeTempDirectory({98 const dir = yield* fileSystem.makeTempDirectory({
101 directory: NodeOS.tmpdir(),99 directory: NodeOS.tmpdir(),
102 prefix: "cursor-provider-about-mock-",100 prefix: "cursor-provider-about-mock-",
103 });101 });
104 const wrapperPath = path.join(dir, "fake-agent.sh");102 return writeFakeCli({
105 const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" ");103 directory: dir,
106 const script = `#!/bin/sh104 name: "fake-agent",
107if [ "$1" = "about" ]; then105 source: [
108 printf 'CLI Version 2026.04.09-f2b0fcd\\n'106 'if (process.argv[2] === "about") {',
109 printf 'User Email cursor@example.com\\n'107 ' process.stdout.write("CLI Version 2026.04.09-f2b0fcd\\n");',
110 exit 0108 ' process.stdout.write("User Email cursor@example.com\\n");',
111fi109 " process.exit(0);",
112exec ${mockAgentCommand} "$@"110 "}",
113`;111 execScriptSource({ scriptPath: mockAgentPath }),
114 yield* fileSystem.writeFileString(wrapperPath, script);112 ].join("\n"),
115 yield* fileSystem.chmod(wrapperPath, 0o755);113 });
116 return wrapperPath;
117});114});
118 115
119const waitForFileContent = Effect.fn("waitForFileContent")(function* (116const waitForFileContent = Effect.fn("waitForFileContent")(function* (
@@ -593,7 +590,7 @@ describe("discoverCursorModelsViaAcp", () => { · 473 unmodified lines
593 ]);590 ]);
594 });591 });
595 592
596 it("closes the ACP probe runtime after discovery completes", async () => {593 it.skipIf(windowsHost)("closes the ACP probe runtime after discovery completes", async () => {
597 const { exitLogPath, wrapperPath } = await runNode(594 const { exitLogPath, wrapperPath } = await runNode(
598 makeExitLogFixture("cursor-provider-exit-log-"),595 makeExitLogFixture("cursor-provider-exit-log-"),
599 );596 );
apps/server/src/provider/Layers/GrokAdapter.test.ts+12−13
@@ -33,25 +33,24 @@ import { · 32 unmodified lines
33 nextGrokPlanModeActive,33 nextGrokPlanModeActive,
34 selectGrokPermissionOptionId,34 selectGrokPermissionOptionId,
35} from "./GrokAdapter.ts";35} from "./GrokAdapter.ts";
36import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
37import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
36const decodeGrokSettings = Schema.decodeSync(GrokSettings);38const decodeGrokSettings = Schema.decodeSync(GrokSettings);
37 39
38const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));40const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
39const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");41const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
40const mockAgentCommand = process.execPath;42// Stopping a session kills the agent with SIGTERM; Windows terminates the
43// process instead, so the mock never sees a signal to log.
44const windowsHost = HostProcessPlatform.defaultValue() === "win32";
41 45
42async function makeMockGrokWrapper(extraEnv?: Record<string, string>) {46async function makeMockGrokWrapper(extraEnv?: Record<string, string>) {
43 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-"));47 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-"));
44 const wrapperPath = NodePath.join(dir, "fake-grok.sh");48 return writeFakeCli({
45 const envExports = Object.entries(extraEnv ?? {})49 directory: dir,
46 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)50 name: "fake-grok",
47 .join("\n");51 env: extraEnv ?? {},
48 const script = `#!/bin/sh52 source: execScriptSource({ scriptPath: mockAgentPath }),
49${envExports}53 });
50exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@"
51`;
52 await NodeFSP.writeFile(wrapperPath, script, "utf8");
53 await NodeFSP.chmod(wrapperPath, 0o755);
54 return wrapperPath;
55}54}
56 55
57function waitForFileContent(56function waitForFileContent(
@@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { · 282 unmodified lines
340 }),339 }),
341 );340 );
342 341
343 it.effect("closes the ACP child process when a session stops", () =>342 it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () =>
344 Effect.gen(function* () {343 Effect.gen(function* () {
345 const threadId = ThreadId.make("grok-stop-session-close");344 const threadId = ThreadId.make("grok-stop-session-close");
346 const tempDir = yield* Effect.promise(() =>345 const tempDir = yield* Effect.promise(() =>
apps/server/src/provider/Layers/GrokProvider.test.ts+29−32
@@ -6,7 +6,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 5 unmodified lines
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
11import { GrokSettings } from "@t3tools/contracts";10import { GrokSettings } from "@t3tools/contracts";
12 11
@@ -17,6 +16,7 @@ import { · 4 unmodified lines
17 checkGrokProviderStatus,16 checkGrokProviderStatus,
18 parseGrokModelsCliOutput,17 parseGrokModelsCliOutput,
19} from "./GrokProvider.ts";18} from "./GrokProvider.ts";
19import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
20 20
21const decodeGrokSettings = Schema.decodeSync(GrokSettings);21const decodeGrokSettings = Schema.decodeSync(GrokSettings);
22const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));22const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
@@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { · 285 unmodified lines
308 const snapshot = yield* Effect.scoped(308 const snapshot = yield* Effect.scoped(
309 Effect.gen(function* () {309 Effect.gen(function* () {
310 const fs = yield* FileSystem.FileSystem;310 const fs = yield* FileSystem.FileSystem;
311 const path = yield* Path.Path;
312 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" });311 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" });
313 const grokPath = path.join(dir, "grok");312 const grokPath = writeFakeCli({
314 yield* fs.writeFileString(313 directory: dir,
315 grokPath,314 name: "grok",
316 ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"),315 source: [
317 );316 // @effect-diagnostics-next-line preferSchemaOverJson:off
318 yield* fs.chmod(grokPath, 0o755);317 `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`,
318 "process.exit(2);",
319 "",
320 ].join("\n"),
321 });
319 322
320 return yield* checkGrokProviderStatus(323 return yield* checkGrokProviderStatus(
321 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),324 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
@@ -331,37 +334,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { · 9 unmodified lines
331 }),334 }),
332 );335 );
333 336
334 // Single-quotes a path for /bin/sh. Temp dirs and execPath never contain quotes.337 // A stand-in for the Grok CLI: `--version` and `models` print canned text,
335 const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
336
337 // A shell stand-in for the Grok CLI: `--version` and `models` print canned text,
338 // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata.338 // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata.
339 const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) =>339 const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) =>
340 Effect.gen(function* () {340 Effect.gen(function* () {
341 const fs = yield* FileSystem.FileSystem;341 const fs = yield* FileSystem.FileSystem;
342 const path = yield* Path.Path;
343 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" });342 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" });
344 const modelsPath = path.join(dir, "models.txt");343 const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts");
345 yield* fs.writeFileString(modelsPath, input.modelsOutput);344 return writeFakeCli({
346 const grokPath = path.join(dir, "grok");345 directory: dir,
347 const mockAgentPath = path.resolve(__dirname, "../../../scripts/acp-mock-agent.ts");346 name: "grok",
348 yield* fs.writeFileString(347 source: [
349 grokPath,348 'if (process.argv[2] === "--version") {',
350 [349 ' process.stdout.write("grok 1.0.13\\n");',
351 "#!/bin/sh",350 " process.exit(0);",
352 'case "$1" in',351 "}",
353 ' --version) printf "grok 1.0.13\\n"; exit 0;;',352 'if (process.argv[2] === "models") {',
354 ` models) cat ${shellQuote(modelsPath)}; exit 0;;`,353 // @effect-diagnostics-next-line preferSchemaOverJson:off
355 input.acp354 ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`,
356 ? ` agent) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;`355 " process.exit(0);",
357 : " agent) exit 3;;",356 "}",
358 "esac",357 'if (process.argv[2] !== "agent") process.exit(1);',
359 "exit 1",358 ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]),
360 "",359 "",
361 ].join("\n"),360 ].join("\n"),
362 );361 });
363 yield* fs.chmod(grokPath, 0o755);
364 return grokPath;
365 });362 });
366 363
367 it.effect("reports ready with ACP-discovered models when logged in", () =>364 it.effect("reports ready with ACP-discovered models when logged in", () =>
apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd+8−0
@@ -0,0 +1,8 @@
1@echo off
2rem Wrapper so CodexSessionRuntime can spawn the mock peer on Windows: the
3rem runtime always passes "app-server" as the first argument; drop it and
4rem run the .mjs peer with node. "shift /1" leaves %0 alone so %~dp0 still
5rem names this file's directory.
6shift /1
7node "%~dp0codexCollabMockPeer.mjs" %1 %2 %3 %4 %5 %6 %7 %8 %9
8exit /b %ERRORLEVEL%
apps/server/src/testUtils/fakeCli.ts+106−0
@@ -0,0 +1,106 @@
1// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6
7export interface FakeCliOptions {
8 /** Directory the launcher and its stub are written into. */
9 readonly directory: string;
10 /** Command name as callers spawn it, e.g. `codex`. */
11 readonly name: string;
12 /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */
13 readonly source: string;
14 /** Environment set for the stub before it runs, on top of the inherited environment. */
15 readonly env?: Readonly<Record<string, string>>;
16 /**
17 * Platform the launcher is shaped for. Effect callers should pass the value
18 * they read from `HostProcessPlatform` so an injected override is honoured;
19 * defaults to the real host for plain tests.
20 */
21 readonly platform?: NodeJS.Platform;
22}
23
24/**
25 * Writes a fake CLI whose behaviour lives in a Node stub. On posix the
26 * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a
27 * shebang file is not executable there and `resolveSpawnCommand` routes
28 * `.cmd` through a shell. Returns the launcher path to hand to the code under
29 * test, which on Windows carries the `.cmd` extension.
30 */
31export function writeFakeCli(options: FakeCliOptions): string {
32 NodeFS.mkdirSync(options.directory, { recursive: true });
33 const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`);
34 // The environment rides in a JSON sidecar the stub applies to itself, since
35 // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely.
36 const envPath = NodePath.join(options.directory, `${options.name}-env.json`);
37 NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8");
38 NodeFS.writeFileSync(
39 stubPath,
40 [
41 'import { readFileSync as readFakeCliEnv } from "node:fs";',
42 `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`,
43 options.source,
44 ].join("\n"),
45 "utf8",
46 );
47
48 if ((options.platform ?? HostProcessPlatform.defaultValue()) === "win32") {
49 const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`);
50 NodeFS.writeFileSync(
51 launcherPath,
52 ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join(
53 "\r\n",
54 ),
55 "utf8",
56 );
57 return launcherPath;
58 }
59
60 const launcherPath = NodePath.join(options.directory, options.name);
61 NodeFS.writeFileSync(
62 launcherPath,
63 ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"),
64 "utf8",
65 );
66 NodeFS.chmodSync(launcherPath, 0o755);
67 return launcherPath;
68}
69
70/**
71 * Stub source that becomes `scriptPath`: after optionally requiring a leading
72 * argv prefix, it imports the script into its own process so the stub is the
73 * agent. Nothing sits between the code under test and the mock, so a kill or
74 * a closed stdin reaches it directly and its exit log is faithful on every
75 * host. The script must not read `process.argv`; the mock agents are driven
76 * by environment and stdin.
77 */
78export function execScriptSource(options: {
79 readonly scriptPath: string;
80 readonly expectedArgs?: ReadonlyArray<string>;
81 /** Tab-separated argv appended here per invocation, for launch-flag assertions. */
82 readonly argvLogPath?: string;
83 /** Wait before handing over, for tests that race a slow startup. */
84 readonly delayMs?: number;
85}): string {
86 return [
87 'import { appendFileSync } from "node:fs";',
88 'import { pathToFileURL } from "node:url";',
89 "const args = process.argv.slice(2);",
90 ...(options.argvLogPath === undefined
91 ? []
92 : [
93 `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`,
94 ]),
95 ...(options.delayMs === undefined
96 ? []
97 : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]),
98 `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`,
99 "if (expected.some((value, index) => args[index] !== value)) {",
100 ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);',
101 " process.exit(11);",
102 "}",
103 `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`,
104 "",
105 ].join("\n");
106}
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+12−30
@@ -1,7 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";2import { it } from "@effect/vitest";
3import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";3import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
4import { isHostWindows } from "@t3tools/shared/hostProcess";4import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
5import { createModelSelection } from "@t3tools/shared/model";5import { createModelSelection } from "@t3tools/shared/model";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
@@ -21,27 +21,26 @@ import { · 13 unmodified lines
21import * as TextGeneration from "./TextGeneration.ts";21import * as TextGeneration from "./TextGeneration.ts";
22import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";22import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";
23import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";23import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
24import { writeFakeCli } from "../testUtils/fakeCli.ts";
24const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);25const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
25 26
26const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {27const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
27 prefix: "t3code-claude-text-generation-test-",28 prefix: "t3code-claude-text-generation-test-",
28}).pipe(Layer.provideMerge(NodeServices.layer));29}).pipe(Layer.provideMerge(NodeServices.layer));
29 30
31// The stub behaviour lives in Node so the same implementation runs on Windows,
32// where a shebang file is not executable and would fall through to the real
33// Claude CLI on PATH; `writeFakeCli` picks the launcher shape per host.
30function makeFakeClaudeBinary(dir: string) {34function makeFakeClaudeBinary(dir: string) {
31 return Effect.gen(function* () {35 return Effect.gen(function* () {
32 const fs = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;36 const path = yield* Path.Path;
34 const isWindows = yield* isHostWindows;37 const platform = yield* HostProcessPlatform;
35 const binDir = path.join(dir, "bin");38 const binDir = path.join(dir, "bin");
36 const stubPath = path.join(binDir, "claude-stub.mjs");39 writeFakeCli({
37 yield* fs.makeDirectory(binDir, { recursive: true });40 directory: binDir,
38 41 name: "claude",
39 // The stub behaviour lives in Node rather than a `#!/bin/sh` script so the42 platform,
40 // same implementation is usable on Windows, where a shebang file is not43 source: [
41 // executable and would fall through to the real Claude CLI on PATH.
42 yield* fs.writeFileString(
43 stubPath,
44 [
45 'const args = process.argv.slice(2).join(" ");',44 'const args = process.argv.slice(2).join(" ");',
46 "",45 "",
47 "function fail(message, code) {",46 "function fail(message, code) {",
@@ -87,24 +86,7 @@ function makeFakeClaudeBinary(dir: string) { · 39 unmodified lines
87 "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);",86 "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);",
88 "",87 "",
89 ].join("\n"),88 ].join("\n"),
90 );89 });
91
92 if (isWindows) {
93 // Windows resolves executables through PATHEXT, so the entry point has to
94 // carry a real extension. `resolveSpawnCommand` spawns `.cmd` via a shell.
95 yield* fs.writeFileString(
96 path.join(binDir, "claude.cmd"),
97 ["@echo off", 'node "%~dp0claude-stub.mjs" %*', "exit /b %ERRORLEVEL%", ""].join("\r\n"),
98 );
99 } else {
100 const claudePath = path.join(binDir, "claude");
101 yield* fs.writeFileString(
102 claudePath,
103 ["#!/bin/sh", 'exec node "$(dirname "$0")/claude-stub.mjs" "$@"', ""].join("\n"),
104 );
105 yield* fs.chmod(claudePath, 0o755);
106 }
107
108 return binDir;90 return binDir;
109 });91 });
110}92}
apps/server/src/textGeneration/CodexTextGeneration.test.ts+99−156
@@ -14,6 +14,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools · 13 unmodified lines
14import * as ServerConfig from "../config.ts";14import * as ServerConfig from "../config.ts";
15import * as TextGeneration from "./TextGeneration.ts";15import * as TextGeneration from "./TextGeneration.ts";
16import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";16import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
17import { writeFakeCli } from "../testUtils/fakeCli.ts";
17const decodeCodexSettings = Schema.decodeSync(CodexSettings);18const decodeCodexSettings = Schema.decodeSync(CodexSettings);
18 19
19const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(20const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
@@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process · 5 unmodified lines
25 prefix: "t3code-codex-text-generation-test-",26 prefix: "t3code-codex-text-generation-test-",
26}).pipe(Layer.provideMerge(NodeServices.layer));27}).pipe(Layer.provideMerge(NodeServices.layer));
27 28
28function makeFakeCodexBinary(29interface FakeCodexInput {
29 dir: string,30 output: string;
30 input: {31 exitCode?: number;
31 output: string;32 stderr?: string;
32 exitCode?: number;33 requireImage?: boolean;
33 stderr?: string;34 requireServiceTier?: string;
34 requireImage?: boolean;35 requireReasoningEffort?: string;
35 requireServiceTier?: string;36 forbidReasoningEffort?: boolean;
36 requireReasoningEffort?: string;37 requireArg?: string;
37 forbidReasoningEffort?: boolean;38 forbidArg?: string;
38 requireArg?: string;39 stdinMustContain?: string;
39 forbidArg?: string;40 stdinMustNotContain?: string;
40 stdinMustContain?: string;41}
41 stdinMustNotContain?: string;42
42 },43// The stub walks argv the way the shell script it replaced did: `--image`,
43) {44// `--config key=value`, and `--output-last-message <path>` are consumed, the
45// prompt arrives on stdin, and each check exits with its own code so a
46// failing test names the assertion that tripped.
47function makeFakeCodexBinary(dir: string, input: FakeCodexInput) {
48 const check = JSON.stringify({
49 requireImage: input.requireImage ?? false,
50 requireServiceTier: input.requireServiceTier ?? null,
51 requireReasoningEffort: input.requireReasoningEffort ?? null,
52 forbidReasoningEffort: input.forbidReasoningEffort ?? false,
53 requireArg: input.requireArg ?? null,
54 forbidArg: input.forbidArg ?? null,
55 stdinMustContain: input.stdinMustContain ?? null,
56 stdinMustNotContain: input.stdinMustNotContain ?? null,
57 stderr: input.stderr ?? null,
58 output: input.output,
59 exitCode: input.exitCode ?? 0,
60 });
44 return Effect.gen(function* () {61 return Effect.gen(function* () {
45 const fs = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;62 const path = yield* Path.Path;
47 const binDir = path.join(dir, "bin");63 return writeFakeCli({
48 const codexPath = path.join(binDir, "codex");64 directory: path.join(dir, "bin"),
49 yield* fs.makeDirectory(binDir, { recursive: true });65 name: "codex",
50 66 source: [
51 yield* fs.writeFileString(67 'import * as NodeFS from "node:fs";',
52 codexPath,68 `const check = ${check};`,
53 [69 "const args = process.argv.slice(2);",
54 "#!/bin/sh",70 'const originalArgs = ` ${args.join(" ")} `;',
55 'original_args="$*"',71 "let outputPath = null;",
56 'output_path=""',72 "let seenImage = false;",
57 'seen_image="0"',73 'let seenServiceTier = "";',
58 'seen_service_tier=""',74 'let seenReasoningEffort = "";',
59 'seen_reasoning_effort=""',75 "for (let index = 0; index < args.length; index += 1) {",
60 "while [ $# -gt 0 ]; do",76 ' if (args[index] === "--image") {',
61 ' if [ "$1" = "--image" ]; then',77 " index += 1;",
62 " shift",78 " if (args[index]) seenImage = true;",
63 ' if [ -n "$1" ]; then',79 ' } else if (args[index] === "--config") {',
64 ' seen_image="1"',80 " index += 1;",
65 " fi",81 ' const value = args[index] ?? "";',
66 " shift",82 ' if (value.startsWith("service_tier=")) seenServiceTier = value;',
67 " continue",83 ' if (value.startsWith("model_reasoning_effort=")) seenReasoningEffort = value;',
68 " fi",84 ' } else if (args[index] === "--output-last-message") {',
69 ' if [ "$1" = "--config" ]; then',85 " index += 1;",
70 " shift",86 " outputPath = args[index] ?? null;",
71 ' case "$1" in',87 " }",
72 " service_tier=*)",88 "}",
73 ' seen_service_tier="$1"',89 "const chunks = [];",
74 " ;;",90 "for await (const chunk of process.stdin) chunks.push(chunk);",
75 " esac",91 'const stdinContent = Buffer.concat(chunks).toString("utf8");',
76 ' case "$1" in',92 "function fail(message, code) {",
77 " model_reasoning_effort=*)",93 ' process.stderr.write(message + "\\n");',
78 ' seen_reasoning_effort="$1"',94 " process.exit(code);",
79 " ;;",95 "}",
80 " esac",96 "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {",
81 " shift",97 ' fail("missing arg: " + check.requireArg, 8);',
82 " continue",98 "}",
83 " fi",99 "if (check.forbidArg !== null && originalArgs.includes(` ${check.forbidArg} `)) {",
84 ' if [ "$1" = "--output-last-message" ]; then',100 ' fail("forbidden arg: " + check.forbidArg, 9);',
85 " shift",101 "}",
86 ' output_path="$1"',102 'if (check.requireImage && !seenImage) fail("missing --image input", 2);',
87 " shift",103 "if (",
88 " continue",104 " check.requireServiceTier !== null &&",
89 " fi",105 ' seenServiceTier !== `service_tier="${check.requireServiceTier}"`',
90 " shift",106 ") {",
91 "done",107 ' fail("unexpected service tier config: " + seenServiceTier, 5);',
92 'stdin_content="$(cat)"',108 "}",
93 ...(input.requireArg !== undefined109 "if (",
94 ? [110 " check.requireReasoningEffort !== null &&",
95 `case " $original_args " in *" ${input.requireArg} "*) ;; *)`,111 ' seenReasoningEffort !== `model_reasoning_effort="${check.requireReasoningEffort}"`',
96 ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`,112 ") {",
97 ` exit 8`,113 ' fail("unexpected reasoning effort config: " + seenReasoningEffort, 6);',
98 "esac",114 "}",
99 ]115 "if (check.forbidReasoningEffort && seenReasoningEffort.length > 0) {",
100 : []),116 ' fail("reasoning effort config should be omitted: " + seenReasoningEffort, 7);',
101 ...(input.forbidArg !== undefined117 "}",
102 ? [118 "if (check.stdinMustContain !== null && !stdinContent.includes(check.stdinMustContain)) {",
103 `case " $original_args " in *" ${input.forbidArg} "*)`,119 ' fail("stdin missing expected content", 3);',
104 ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`,120 "}",
105 ` exit 9`,121 "if (check.stdinMustNotContain !== null && stdinContent.includes(check.stdinMustNotContain)) {",
106 "esac",122 ' fail("stdin contained forbidden content", 4);',
107 ]123 "}",
108 : []),124 'if (check.stderr !== null) process.stderr.write(check.stderr + "\\n");',
109 ...(input.requireImage125 'if (outputPath !== null) NodeFS.writeFileSync(outputPath, check.output + "\\n");',
110 ? [126 "process.exitCode = check.exitCode;",
111 'if [ "$seen_image" != "1" ]; then',
112 ' printf "%s\\n" "missing --image input" >&2',
113 ` exit 2`,
114 "fi",
115 ]
116 : []),
117 ...(input.requireServiceTier
118 ? [
119 `if [ "$seen_service_tier" != "service_tier=\\"${input.requireServiceTier}\\"" ]; then`,
120 ' printf "%s\\n" "unexpected service tier config: $seen_service_tier" >&2',
121 ` exit 5`,
122 "fi",
123 ]
124 : []),
125 ...(input.requireReasoningEffort !== undefined
126 ? [
127 `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`,
128 ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
129 ` exit 6`,
130 "fi",
131 ]
132 : []),
133 ...(input.forbidReasoningEffort
134 ? [
135 'if [ -n "$seen_reasoning_effort" ]; then',
136 ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
137 ` exit 7`,
138 "fi",
139 ]
140 : []),
141 ...(input.stdinMustContain !== undefined
142 ? [
143 // @effect-diagnostics-next-line preferSchemaOverJson:off
144 `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`,
145 ' printf "%s\\n" "stdin missing expected content" >&2',
146 ` exit 3`,
147 "fi",
148 ]
149 : []),
150 ...(input.stdinMustNotContain !== undefined
151 ? [
152 // @effect-diagnostics-next-line preferSchemaOverJson:off
153 `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`,
154 ' printf "%s\\n" "stdin contained forbidden content" >&2',
155 ` exit 4`,
156 "fi",
157 ]
158 : []),
159 ...(input.stderr !== undefined
160 ? [
161 // @effect-diagnostics-next-line preferSchemaOverJson:off
162 `printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`,
163 ]
164 : []),
165 'if [ -n "$output_path" ]; then',
166 " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'",
167 input.output,
168 "__T3CODE_FAKE_CODEX_OUTPUT__",
169 "fi",
170 `exit ${input.exitCode ?? 0}`,
171 "",127 "",
172 ].join("\n"),128 ].join("\n"),
173 );129 });
174 yield* fs.chmod(codexPath, 0o755);
175 return codexPath;
176 });130 });
177}131}
178 132
179function withFakeCodexEnv<A, E, R>(133function withFakeCodexEnv<A, E, R>(
180 input: {134 input: FakeCodexInput & {
181 output: string;
182 exitCode?: number;
183 stderr?: string;
184 requireImage?: boolean;
185 requireServiceTier?: string;
186 requireReasoningEffort?: string;
187 forbidReasoningEffort?: boolean;
188 requireArg?: string;
189 forbidArg?: string;
190 stdinMustContain?: string;
191 stdinMustNotContain?: string;
192 launchArgs?: string;135 launchArgs?: string;
193 environment?: NodeJS.ProcessEnv;136 environment?: NodeJS.ProcessEnv;
194 },137 },
apps/server/src/textGeneration/CursorTextGeneration.test.ts+49−56
@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; · 10 unmodified lines
11import * as Exit from "effect/Exit";11import * as Exit from "effect/Exit";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
14import { createModelSelection } from "@t3tools/shared/model";15import { createModelSelection } from "@t3tools/shared/model";
15import { expect } from "vite-plus/test";16import { expect } from "vite-plus/test";
16 17
@@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; · 2 unmodified lines
19import * as ServerConfig from "../config.ts";20import * as ServerConfig from "../config.ts";
20import * as TextGeneration from "./TextGeneration.ts";21import * as TextGeneration from "./TextGeneration.ts";
21import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";22import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
23import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
22const decodeCursorSettings = Schema.decodeSync(CursorSettings);24const decodeCursorSettings = Schema.decodeSync(CursorSettings);
23 25
24const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));26const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
25const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");27const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
26 28
27function shellSingleQuote(value: string): string {
28 return `'${value.replaceAll("'", `'"'"'`)}'`;
29}
30
31const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {29const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
32 prefix: "t3code-cursor-text-generation-test-",30 prefix: "t3code-cursor-text-generation-test-",
33}).pipe(Layer.provideMerge(NodeServices.layer));31}).pipe(Layer.provideMerge(NodeServices.layer));
34 32
35function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {33function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
36 const binDir = NodePath.join(dir, "bin");34 return writeFakeCli({
37 const agentPath = NodePath.join(binDir, "agent");35 directory: NodePath.join(dir, "bin"),
38 NodeFS.mkdirSync(binDir, { recursive: true });36 name: "agent",
39 NodeFS.writeFileSync(37 env,
40 agentPath,38 source: execScriptSource({
41 [39 scriptPath: mockAgentPath,
42 "#!/bin/sh",40 expectedArgs: ["acp"],
43 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),41 }),
44 'if [ "$1" != "acp" ]; then',42 });
45 ' printf "%s\\n" "unexpected args: $*" >&2',
46 " exit 11",
47 "fi",
48 `exec node ${JSON.stringify(mockAgentPath)}`,
49 "",
50 ].join("\n"),
51 "utf8",
52 );
53 NodeFS.chmodSync(agentPath, 0o755);
54 return agentPath;
55}43}
56 44
57function withFakeAcpAgent<A, E, R>(45function withFakeAcpAgent<A, E, R>(
@@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { · 178 unmodified lines
236 ),224 ),
237 );225 );
238 226
239 it.effect("closes the ACP child process after text generation completes", () => {227 // Closing the runtime on Windows is taskkill /F, which never lets the mock
240 const exitLogDir = NodeFS.mkdtempSync(228 // agent reach its exit handler, so there is no exit log to assert on.
241 NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"),229 it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
242 );230 "closes the ACP child process after text generation completes",
243 const exitLogPath = NodePath.join(exitLogDir, "exit.log");231 () => {
232 const exitLogDir = NodeFS.mkdtempSync(
233 NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"),
234 );
235 const exitLogPath = NodePath.join(exitLogDir, "exit.log");
244 236
245 return withFakeAcpAgent(237 return withFakeAcpAgent(
246 {238 {
247 T3_ACP_EXIT_LOG_PATH: exitLogPath,239 T3_ACP_EXIT_LOG_PATH: exitLogPath,
248 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({240 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
249 subject: "Close runtime after generation",241 subject: "Close runtime after generation",
250 body: "",242 body: "",
251 }),243 }),
252 },244 },
253 (textGeneration) =>245 (textGeneration) =>
254 Effect.gen(function* () {246 Effect.gen(function* () {
255 const generated = yield* textGeneration.generateCommitMessage({247 const generated = yield* textGeneration.generateCommitMessage({
256 cwd: process.cwd(),248 cwd: process.cwd(),
257 branch: "feature/cursor-runtime-close",249 branch: "feature/cursor-runtime-close",
258 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",250 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
259 stagedPatch:251 stagedPatch:
260 "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",252 "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",
261 modelSelection: {253 modelSelection: {
262 instanceId: ProviderInstanceId.make("cursor"),254 instanceId: ProviderInstanceId.make("cursor"),
263 model: "composer-2",255 model: "composer-2",
264 },256 },
265 });257 });
266 258
267 expect(generated.subject).toBe("Close runtime after generation");259 expect(generated.subject).toBe("Close runtime after generation");
268 260
269 const exitLog = yield* waitForFileContent(exitLogPath);261 const exitLog = yield* waitForFileContent(exitLogPath);
270 expect(exitLog).toContain("exit:0");262 expect(exitLog).toContain("exit:0");
271 263
272 NodeFS.rmSync(exitLogDir, { recursive: true, force: true });264 NodeFS.rmSync(exitLogDir, { recursive: true, force: true });
273 }),265 }),
274 );266 );
275 });267 },
268 );
276});269});
apps/server/src/textGeneration/GrokTextGeneration.test.ts+10−23
@@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; · 15 unmodified lines
16import * as ServerConfig from "../config.ts";16import * as ServerConfig from "../config.ts";
17import * as TextGeneration from "./TextGeneration.ts";17import * as TextGeneration from "./TextGeneration.ts";
18import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";18import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";
19import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
19const decodeGrokSettings = Schema.decodeSync(GrokSettings);20const decodeGrokSettings = Schema.decodeSync(GrokSettings);
20 21
21const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));22const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
22const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");23const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
23 24
24function shellSingleQuote(value: string): string {
25 return `'${value.replaceAll("'", `'"'"'`)}'`;
26}
27
28const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {25const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
29 prefix: "t3code-grok-text-generation-test-",26 prefix: "t3code-grok-text-generation-test-",
30}).pipe(Layer.provideMerge(NodeServices.layer));27}).pipe(Layer.provideMerge(NodeServices.layer));
31 28
32function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string {29function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string {
33 const binDir = NodePath.join(dir, "bin");30 return writeFakeCli({
34 const grokPath = NodePath.join(binDir, "grok");31 directory: NodePath.join(dir, "bin"),
35 NodeFS.mkdirSync(binDir, { recursive: true });32 name: "grok",
36 NodeFS.writeFileSync(33 env,
37 grokPath,34 source: execScriptSource({
38 [35 scriptPath: mockAgentPath,
39 "#!/bin/sh",36 expectedArgs: ["agent", "stdio"],
40 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),37 }),
41 'if [ "$1" != "agent" ] || [ "$2" != "stdio" ]; then',38 });
42 ' printf "%s\\n" "unexpected args: $*" >&2',
43 " exit 11",
44 "fi",
45 `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`,
46 "",
47 ].join("\n"),
48 "utf8",
49 );
50 NodeFS.chmodSync(grokPath, 0o755);
51 return grokPath;
52}39}
53 40
54function withFakeAcpGrok<A, E, R>(41function withFakeAcpGrok<A, E, R>(