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 @@
11 # git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt
22 * 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
2323
2424 import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" };
2525 import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";
26+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2627
2728 const ROOT = wireFixture.rootThreadId;
2829 const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];
@@ -157,7 +158,11 @@ function readRecordedRequests() { · 128 unmodified lines
157158 }
158159
159160 const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json");
160−const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh");
161+// Windows cannot run the shebang wrapper; the .cmd sibling does the same job.
162+const peerPath = NodePath.join(
163+ import.meta.dirname,
164+ `../testFixtures/codexCollabMockPeer.${HostProcessPlatform.defaultValue() === "win32" ? "cmd" : "sh"}`,
165+);
161166
162167 describe("CodexSessionRuntime collab integration", () => {
163168 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
3030 import { ServerSettingsService } from "../../serverSettings.ts";
3131 import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
3232 import { makeCursorAdapter } from "./CursorAdapter.ts";
33+import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
34+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3335 const decodeCursorSettings = Schema.decodeSync(CursorSettings);
3436
3537 // 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
3941
4042 const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
4143 const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
42−const mockAgentCommand = "node";
43−const mockAgentArgs = [mockAgentPath] as const;
44−
44+// Stopping a session kills the agent with SIGTERM; Windows terminates the
45+// process instead, so the mock never sees a signal to log.
46+const windowsHost = HostProcessPlatform.defaultValue() === "win32";
4547 async function makeMockAgentWrapper(
4648 extraEnv?: Record<string, string>,
4749 options?: { initialDelaySeconds?: number },
4850 ) {
4951 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-"));
50− const wrapperPath = NodePath.join(dir, "fake-agent.sh");
51− const envExports = Object.entries(extraEnv ?? {})
52− .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
53− .join("\n");
54− const script = `#!/bin/sh
55−${envExports}
56−${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""}
57−exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@"
58−`;
59− await NodeFSP.writeFile(wrapperPath, script, "utf8");
60− await NodeFSP.chmod(wrapperPath, 0o755);
61− return wrapperPath;
52+ return writeFakeCli({
53+ directory: dir,
54+ name: "fake-agent",
55+ env: extraEnv ?? {},
56+ source: execScriptSource({
57+ scriptPath: mockAgentPath,
58+ ...(options?.initialDelaySeconds === undefined
59+ ? {}
60+ : { delayMs: Math.round(options.initialDelaySeconds * 1000) }),
61+ }),
62+ });
6263 }
6364
6465 async function makeProbeWrapper(
@@ -67,20 +68,12 @@ async function makeProbeWrapper( · 2 unmodified lines
6768 extraEnv?: Record<string, string>,
6869 ) {
6970 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-"));
70− const wrapperPath = NodePath.join(dir, "fake-agent.sh");
71− const envExports = Object.entries(extraEnv ?? {})
72− .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
73− .join("\n");
74− const script = `#!/bin/sh
75−printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)}
76−printf '\n' >> ${JSON.stringify(argvLogPath)}
77−export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)}
78−${envExports}
79−exec ${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;
71+ return writeFakeCli({
72+ directory: dir,
73+ name: "fake-agent",
74+ env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv },
75+ source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }),
76+ });
8477 }
8578
8679 async function readArgvLog(filePath: string) {
@@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { · 303 unmodified lines
390383 }),
391384 );
392385
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", () =>
394387 Effect.gen(function* () {
395388 const adapter = yield* CursorAdapter;
396389 const settings = yield* ServerSettingsService;
@@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { · 25 unmodified lines
422415 }),
423416 );
424417
425− it.effect(
418+ it.effect.skipIf(windowsHost)(
426419 "serializes concurrent startSession calls for the same thread and closes the replaced ACP session",
427420 () =>
428421 Effect.gen(function* () {
apps/server/src/provider/Layers/CursorProvider.test.ts+25−28
@@ -30,6 +30,8 @@ import { · 29 unmodified lines
3030 probeCursorSkills,
3131 rewriteCursorSkillMentions,
3232 } from "../Drivers/CursorSkills.ts";
33+import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
34+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3335
3436 const runNode = <A, E>(
3537 effect: Effect.Effect<
@@ -39,6 +41,10 @@ const runNode = <A, E>( · 3 unmodified lines
3941 >,
4042 ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));
4143
44+// Closing the probe kills the agent with SIGTERM; Windows terminates the
45+// process instead, so the mock never sees a signal to log.
46+const windowsHost = HostProcessPlatform.defaultValue() === "win32";
47+
4248 const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () {
4349 const path = yield* Path.Path;
4450 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
7379 extraEnv?: Record<string, string>,
7480 ) {
7581 const fileSystem = yield* FileSystem.FileSystem;
76− const path = yield* Path.Path;
7782 const mockAgentPath = yield* resolveMockAgentPath();
7883 const dir = yield* fileSystem.makeTempDirectory({
7984 directory: NodeOS.tmpdir(),
8085 prefix: "cursor-provider-mock-",
8186 });
82− const wrapperPath = path.join(dir, "fake-agent.sh");
83− const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" ");
84− const envExports = Object.entries(extraEnv ?? {})
85− .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
86− .join("\n");
87− const script = `#!/bin/sh
88−${envExports}
89−exec ${mockAgentCommand} "$@"
90−`;
91− yield* fileSystem.writeFileString(wrapperPath, script);
92− yield* fileSystem.chmod(wrapperPath, 0o755);
93− return wrapperPath;
87+ return writeFakeCli({
88+ directory: dir,
89+ name: "fake-agent",
90+ env: extraEnv ?? {},
91+ source: execScriptSource({ scriptPath: mockAgentPath }),
92+ });
9493 });
9594
9695 const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () {
9796 const fileSystem = yield* FileSystem.FileSystem;
98− const path = yield* Path.Path;
9997 const mockAgentPath = yield* resolveMockAgentPath();
10098 const dir = yield* fileSystem.makeTempDirectory({
10199 directory: NodeOS.tmpdir(),
102100 prefix: "cursor-provider-about-mock-",
103101 });
104− const wrapperPath = path.join(dir, "fake-agent.sh");
105− const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" ");
106− const script = `#!/bin/sh
107−if [ "$1" = "about" ]; then
108− printf 'CLI Version 2026.04.09-f2b0fcd\\n'
109− printf 'User Email cursor@example.com\\n'
110− exit 0
111−fi
112−exec ${mockAgentCommand} "$@"
113−`;
114− yield* fileSystem.writeFileString(wrapperPath, script);
115− yield* fileSystem.chmod(wrapperPath, 0o755);
116− return wrapperPath;
102+ return writeFakeCli({
103+ directory: dir,
104+ name: "fake-agent",
105+ source: [
106+ 'if (process.argv[2] === "about") {',
107+ ' process.stdout.write("CLI Version 2026.04.09-f2b0fcd\\n");',
108+ ' process.stdout.write("User Email cursor@example.com\\n");',
109+ " process.exit(0);",
110+ "}",
111+ execScriptSource({ scriptPath: mockAgentPath }),
112+ ].join("\n"),
113+ });
117114 });
118115
119116 const waitForFileContent = Effect.fn("waitForFileContent")(function* (
@@ -593,7 +590,7 @@ describe("discoverCursorModelsViaAcp", () => { · 473 unmodified lines
593590 ]);
594591 });
595592
596− it("closes the ACP probe runtime after discovery completes", async () => {
593+ it.skipIf(windowsHost)("closes the ACP probe runtime after discovery completes", async () => {
597594 const { exitLogPath, wrapperPath } = await runNode(
598595 makeExitLogFixture("cursor-provider-exit-log-"),
599596 );
apps/server/src/provider/Layers/GrokAdapter.test.ts+12−13
@@ -33,25 +33,24 @@ import { · 32 unmodified lines
3333 nextGrokPlanModeActive,
3434 selectGrokPermissionOptionId,
3535 } from "./GrokAdapter.ts";
36+import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
37+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3638 const decodeGrokSettings = Schema.decodeSync(GrokSettings);
3739
3840 const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
3941 const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
40−const 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.
44+const windowsHost = HostProcessPlatform.defaultValue() === "win32";
4145
4246 async function makeMockGrokWrapper(extraEnv?: Record<string, string>) {
4347 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-"));
44− const wrapperPath = NodePath.join(dir, "fake-grok.sh");
45− const envExports = Object.entries(extraEnv ?? {})
46− .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
47− .join("\n");
48− const script = `#!/bin/sh
49−${envExports}
50−exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@"
51−`;
52− await NodeFSP.writeFile(wrapperPath, script, "utf8");
53− await NodeFSP.chmod(wrapperPath, 0o755);
54− return wrapperPath;
48+ return writeFakeCli({
49+ directory: dir,
50+ name: "fake-grok",
51+ env: extraEnv ?? {},
52+ source: execScriptSource({ scriptPath: mockAgentPath }),
53+ });
5554 }
5655
5756 function waitForFileContent(
@@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { · 282 unmodified lines
340339 }),
341340 );
342341
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", () =>
344343 Effect.gen(function* () {
345344 const threadId = ThreadId.make("grok-stop-session-close");
346345 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
66 import { describe, expect, it } from "@effect/vitest";
77 import * as Effect from "effect/Effect";
88 import * as FileSystem from "effect/FileSystem";
9−import * as Path from "effect/Path";
109 import * as Schema from "effect/Schema";
1110 import { GrokSettings } from "@t3tools/contracts";
1211
@@ -17,6 +16,7 @@ import { · 4 unmodified lines
1716 checkGrokProviderStatus,
1817 parseGrokModelsCliOutput,
1918 } from "./GrokProvider.ts";
19+import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
2020
2121 const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2222 const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
@@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { · 285 unmodified lines
308308 const snapshot = yield* Effect.scoped(
309309 Effect.gen(function* () {
310310 const fs = yield* FileSystem.FileSystem;
311− const path = yield* Path.Path;
312311 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" });
313− const grokPath = path.join(dir, "grok");
314− yield* fs.writeFileString(
315− grokPath,
316− ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"),
317− );
318− yield* fs.chmod(grokPath, 0o755);
312+ const grokPath = writeFakeCli({
313+ directory: dir,
314+ name: "grok",
315+ source: [
316+ // @effect-diagnostics-next-line preferSchemaOverJson:off
317+ `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`,
318+ "process.exit(2);",
319+ "",
320+ ].join("\n"),
321+ });
319322
320323 return yield* checkGrokProviderStatus(
321324 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
@@ -331,37 +334,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { · 9 unmodified lines
331334 }),
332335 );
333336
334− // Single-quotes a path for /bin/sh. Temp dirs and execPath never contain quotes.
335− const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
336−
337− // A shell stand-in for the Grok CLI: `--version` and `models` print canned text,
337+ // A stand-in for the Grok CLI: `--version` and `models` print canned text,
338338 // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata.
339339 const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) =>
340340 Effect.gen(function* () {
341341 const fs = yield* FileSystem.FileSystem;
342− const path = yield* Path.Path;
343342 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" });
344− const modelsPath = path.join(dir, "models.txt");
345− yield* fs.writeFileString(modelsPath, input.modelsOutput);
346− const grokPath = path.join(dir, "grok");
347− const mockAgentPath = path.resolve(__dirname, "../../../scripts/acp-mock-agent.ts");
348− yield* fs.writeFileString(
349− grokPath,
350− [
351− "#!/bin/sh",
352− 'case "$1" in',
353− ' --version) printf "grok 1.0.13\\n"; exit 0;;',
354− ` models) cat ${shellQuote(modelsPath)}; exit 0;;`,
355− input.acp
356− ? ` agent) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;`
357− : " agent) exit 3;;",
358− "esac",
359− "exit 1",
343+ const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts");
344+ return writeFakeCli({
345+ directory: dir,
346+ name: "grok",
347+ source: [
348+ 'if (process.argv[2] === "--version") {',
349+ ' process.stdout.write("grok 1.0.13\\n");',
350+ " process.exit(0);",
351+ "}",
352+ 'if (process.argv[2] === "models") {',
353+ // @effect-diagnostics-next-line preferSchemaOverJson:off
354+ ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`,
355+ " process.exit(0);",
356+ "}",
357+ 'if (process.argv[2] !== "agent") process.exit(1);',
358+ ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]),
360359 "",
361360 ].join("\n"),
362− );
363− yield* fs.chmod(grokPath, 0o755);
364− return grokPath;
361+ });
365362 });
366363
367364 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
2+rem Wrapper so CodexSessionRuntime can spawn the mock peer on Windows: the
3+rem runtime always passes "app-server" as the first argument; drop it and
4+rem run the .mjs peer with node. "shift /1" leaves %0 alone so %~dp0 still
5+rem names this file's directory.
6+shift /1
7+node "%~dp0codexCollabMockPeer.mjs" %1 %2 %3 %4 %5 %6 %7 %8 %9
8+exit /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.
2+import * as NodeFS from "node:fs";
3+import * as NodePath from "node:path";
4+
5+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6+
7+export 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+ */
31+export 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+ */
78+export 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 @@
11 import * as NodeServices from "@effect/platform-node/NodeServices";
22 import { it } from "@effect/vitest";
33 import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
4−import { isHostWindows } from "@t3tools/shared/hostProcess";
4+import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
55 import { createModelSelection } from "@t3tools/shared/model";
66 import * as Effect from "effect/Effect";
77 import * as FileSystem from "effect/FileSystem";
@@ -21,27 +21,26 @@ import { · 13 unmodified lines
2121 import * as TextGeneration from "./TextGeneration.ts";
2222 import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";
2323 import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
24+import { writeFakeCli } from "../testUtils/fakeCli.ts";
2425 const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
2526
2627 const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
2728 prefix: "t3code-claude-text-generation-test-",
2829 }).pipe(Layer.provideMerge(NodeServices.layer));
2930
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.
3034 function makeFakeClaudeBinary(dir: string) {
3135 return Effect.gen(function* () {
32− const fs = yield* FileSystem.FileSystem;
3336 const path = yield* Path.Path;
34− const isWindows = yield* isHostWindows;
37+ const platform = yield* HostProcessPlatform;
3538 const binDir = path.join(dir, "bin");
36− const stubPath = path.join(binDir, "claude-stub.mjs");
37− yield* fs.makeDirectory(binDir, { recursive: true });
38−
39− // The stub behaviour lives in Node rather than a `#!/bin/sh` script so the
40− // same implementation is usable on Windows, where a shebang file is not
41− // executable and would fall through to the real Claude CLI on PATH.
42− yield* fs.writeFileString(
43− stubPath,
44− [
39+ writeFakeCli({
40+ directory: binDir,
41+ name: "claude",
42+ platform,
43+ source: [
4544 'const args = process.argv.slice(2).join(" ");',
4645 "",
4746 "function fail(message, code) {",
@@ -87,24 +86,7 @@ function makeFakeClaudeBinary(dir: string) { · 39 unmodified lines
8786 "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);",
8887 "",
8988 ].join("\n"),
90− );
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−
89+ });
10890 return binDir;
10991 });
11092 }
apps/server/src/textGeneration/CodexTextGeneration.test.ts+99−156
@@ -14,6 +14,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools · 13 unmodified lines
1414 import * as ServerConfig from "../config.ts";
1515 import * as TextGeneration from "./TextGeneration.ts";
1616 import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
17+import { writeFakeCli } from "../testUtils/fakeCli.ts";
1718 const decodeCodexSettings = Schema.decodeSync(CodexSettings);
1819
1920 const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
@@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process · 5 unmodified lines
2526 prefix: "t3code-codex-text-generation-test-",
2627 }).pipe(Layer.provideMerge(NodeServices.layer));
2728
28−function makeFakeCodexBinary(
29− dir: string,
30− input: {
31− output: string;
32− exitCode?: number;
33− stderr?: string;
34− requireImage?: boolean;
35− requireServiceTier?: string;
36− requireReasoningEffort?: string;
37− forbidReasoningEffort?: boolean;
38− requireArg?: string;
39− forbidArg?: string;
40− stdinMustContain?: string;
41− stdinMustNotContain?: string;
42− },
43−) {
29+interface FakeCodexInput {
30+ output: string;
31+ exitCode?: number;
32+ stderr?: string;
33+ requireImage?: boolean;
34+ requireServiceTier?: string;
35+ requireReasoningEffort?: string;
36+ forbidReasoningEffort?: boolean;
37+ requireArg?: string;
38+ forbidArg?: string;
39+ stdinMustContain?: string;
40+ stdinMustNotContain?: string;
41+}
42+
43+// The stub walks argv the way the shell script it replaced did: `--image`,
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.
47+function 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+ });
4461 return Effect.gen(function* () {
45− const fs = yield* FileSystem.FileSystem;
4662 const path = yield* Path.Path;
47− const binDir = path.join(dir, "bin");
48− const codexPath = path.join(binDir, "codex");
49− yield* fs.makeDirectory(binDir, { recursive: true });
50−
51− yield* fs.writeFileString(
52− codexPath,
53− [
54− "#!/bin/sh",
55− 'original_args="$*"',
56− 'output_path=""',
57− 'seen_image="0"',
58− 'seen_service_tier=""',
59− 'seen_reasoning_effort=""',
60− "while [ $# -gt 0 ]; do",
61− ' if [ "$1" = "--image" ]; then',
62− " shift",
63− ' if [ -n "$1" ]; then',
64− ' seen_image="1"',
65− " fi",
66− " shift",
67− " continue",
68− " fi",
69− ' if [ "$1" = "--config" ]; then',
70− " shift",
71− ' case "$1" in',
72− " service_tier=*)",
73− ' seen_service_tier="$1"',
74− " ;;",
75− " esac",
76− ' case "$1" in',
77− " model_reasoning_effort=*)",
78− ' seen_reasoning_effort="$1"',
79− " ;;",
80− " esac",
81− " shift",
82− " continue",
83− " fi",
84− ' if [ "$1" = "--output-last-message" ]; then',
85− " shift",
86− ' output_path="$1"',
87− " shift",
88− " continue",
89− " fi",
90− " shift",
91− "done",
92− 'stdin_content="$(cat)"',
93− ...(input.requireArg !== undefined
94− ? [
95− `case " $original_args " in *" ${input.requireArg} "*) ;; *)`,
96− ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`,
97− ` exit 8`,
98− "esac",
99− ]
100− : []),
101− ...(input.forbidArg !== undefined
102− ? [
103− `case " $original_args " in *" ${input.forbidArg} "*)`,
104− ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`,
105− ` exit 9`,
106− "esac",
107− ]
108− : []),
109− ...(input.requireImage
110− ? [
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}`,
63+ return writeFakeCli({
64+ directory: path.join(dir, "bin"),
65+ name: "codex",
66+ source: [
67+ 'import * as NodeFS from "node:fs";',
68+ `const check = ${check};`,
69+ "const args = process.argv.slice(2);",
70+ 'const originalArgs = ` ${args.join(" ")} `;',
71+ "let outputPath = null;",
72+ "let seenImage = false;",
73+ 'let seenServiceTier = "";',
74+ 'let seenReasoningEffort = "";',
75+ "for (let index = 0; index < args.length; index += 1) {",
76+ ' if (args[index] === "--image") {',
77+ " index += 1;",
78+ " if (args[index]) seenImage = true;",
79+ ' } else if (args[index] === "--config") {',
80+ " index += 1;",
81+ ' const value = args[index] ?? "";',
82+ ' if (value.startsWith("service_tier=")) seenServiceTier = value;',
83+ ' if (value.startsWith("model_reasoning_effort=")) seenReasoningEffort = value;',
84+ ' } else if (args[index] === "--output-last-message") {',
85+ " index += 1;",
86+ " outputPath = args[index] ?? null;",
87+ " }",
88+ "}",
89+ "const chunks = [];",
90+ "for await (const chunk of process.stdin) chunks.push(chunk);",
91+ 'const stdinContent = Buffer.concat(chunks).toString("utf8");',
92+ "function fail(message, code) {",
93+ ' process.stderr.write(message + "\\n");',
94+ " process.exit(code);",
95+ "}",
96+ "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {",
97+ ' fail("missing arg: " + check.requireArg, 8);',
98+ "}",
99+ "if (check.forbidArg !== null && originalArgs.includes(` ${check.forbidArg} `)) {",
100+ ' fail("forbidden arg: " + check.forbidArg, 9);',
101+ "}",
102+ 'if (check.requireImage && !seenImage) fail("missing --image input", 2);',
103+ "if (",
104+ " check.requireServiceTier !== null &&",
105+ ' seenServiceTier !== `service_tier="${check.requireServiceTier}"`',
106+ ") {",
107+ ' fail("unexpected service tier config: " + seenServiceTier, 5);',
108+ "}",
109+ "if (",
110+ " check.requireReasoningEffort !== null &&",
111+ ' seenReasoningEffort !== `model_reasoning_effort="${check.requireReasoningEffort}"`',
112+ ") {",
113+ ' fail("unexpected reasoning effort config: " + seenReasoningEffort, 6);',
114+ "}",
115+ "if (check.forbidReasoningEffort && seenReasoningEffort.length > 0) {",
116+ ' fail("reasoning effort config should be omitted: " + seenReasoningEffort, 7);',
117+ "}",
118+ "if (check.stdinMustContain !== null && !stdinContent.includes(check.stdinMustContain)) {",
119+ ' fail("stdin missing expected content", 3);',
120+ "}",
121+ "if (check.stdinMustNotContain !== null && stdinContent.includes(check.stdinMustNotContain)) {",
122+ ' fail("stdin contained forbidden content", 4);',
123+ "}",
124+ 'if (check.stderr !== null) process.stderr.write(check.stderr + "\\n");',
125+ 'if (outputPath !== null) NodeFS.writeFileSync(outputPath, check.output + "\\n");',
126+ "process.exitCode = check.exitCode;",
171127 "",
172128 ].join("\n"),
173− );
174− yield* fs.chmod(codexPath, 0o755);
175− return codexPath;
129+ });
176130 });
177131 }
178132
179133 function withFakeCodexEnv<A, E, R>(
180− input: {
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;
134+ input: FakeCodexInput & {
192135 launchArgs?: string;
193136 environment?: NodeJS.ProcessEnv;
194137 },
apps/server/src/textGeneration/CursorTextGeneration.test.ts+49−56
@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; · 10 unmodified lines
1111 import * as Exit from "effect/Exit";
1212 import * as Layer from "effect/Layer";
1313 import * as Schema from "effect/Schema";
14+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1415 import { createModelSelection } from "@t3tools/shared/model";
1516 import { expect } from "vite-plus/test";
1617
@@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; · 2 unmodified lines
1920 import * as ServerConfig from "../config.ts";
2021 import * as TextGeneration from "./TextGeneration.ts";
2122 import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
23+import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
2224 const decodeCursorSettings = Schema.decodeSync(CursorSettings);
2325
2426 const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
2527 const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
2628
27−function shellSingleQuote(value: string): string {
28− return `'${value.replaceAll("'", `'"'"'`)}'`;
29−}
30−
3129 const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
3230 prefix: "t3code-cursor-text-generation-test-",
3331 }).pipe(Layer.provideMerge(NodeServices.layer));
3432
3533 function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
36− const binDir = NodePath.join(dir, "bin");
37− const agentPath = NodePath.join(binDir, "agent");
38− NodeFS.mkdirSync(binDir, { recursive: true });
39− NodeFS.writeFileSync(
40− agentPath,
41− [
42− "#!/bin/sh",
43− ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
44− 'if [ "$1" != "acp" ]; then',
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;
34+ return writeFakeCli({
35+ directory: NodePath.join(dir, "bin"),
36+ name: "agent",
37+ env,
38+ source: execScriptSource({
39+ scriptPath: mockAgentPath,
40+ expectedArgs: ["acp"],
41+ }),
42+ });
5543 }
5644
5745 function withFakeAcpAgent<A, E, R>(
@@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { · 178 unmodified lines
236224 ),
237225 );
238226
239− it.effect("closes the ACP child process after text generation completes", () => {
240− const exitLogDir = NodeFS.mkdtempSync(
241− NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"),
242− );
243− const exitLogPath = NodePath.join(exitLogDir, "exit.log");
227+ // Closing the runtime on Windows is taskkill /F, which never lets the mock
228+ // agent reach its exit handler, so there is no exit log to assert on.
229+ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
230+ "closes the ACP child process after text generation completes",
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");
244236
245− return withFakeAcpAgent(
246− {
247− T3_ACP_EXIT_LOG_PATH: exitLogPath,
248− T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
249− subject: "Close runtime after generation",
250− body: "",
251− }),
252− },
253− (textGeneration) =>
254− Effect.gen(function* () {
255− const generated = yield* textGeneration.generateCommitMessage({
256− cwd: process.cwd(),
257− branch: "feature/cursor-runtime-close",
258− stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
259− stagedPatch:
260− "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",
261− modelSelection: {
262− instanceId: ProviderInstanceId.make("cursor"),
263− model: "composer-2",
264− },
265− });
237+ return withFakeAcpAgent(
238+ {
239+ T3_ACP_EXIT_LOG_PATH: exitLogPath,
240+ T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
241+ subject: "Close runtime after generation",
242+ body: "",
243+ }),
244+ },
245+ (textGeneration) =>
246+ Effect.gen(function* () {
247+ const generated = yield* textGeneration.generateCommitMessage({
248+ cwd: process.cwd(),
249+ branch: "feature/cursor-runtime-close",
250+ stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
251+ stagedPatch:
252+ "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",
253+ modelSelection: {
254+ instanceId: ProviderInstanceId.make("cursor"),
255+ model: "composer-2",
256+ },
257+ });
266258
267− expect(generated.subject).toBe("Close runtime after generation");
259+ expect(generated.subject).toBe("Close runtime after generation");
268260
269− const exitLog = yield* waitForFileContent(exitLogPath);
270− expect(exitLog).toContain("exit:0");
261+ const exitLog = yield* waitForFileContent(exitLogPath);
262+ expect(exitLog).toContain("exit:0");
271263
272− NodeFS.rmSync(exitLogDir, { recursive: true, force: true });
273− }),
274− );
275− });
264+ NodeFS.rmSync(exitLogDir, { recursive: true, force: true });
265+ }),
266+ );
267+ },
268+ );
276269 });
apps/server/src/textGeneration/GrokTextGeneration.test.ts+10−23
@@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; · 15 unmodified lines
1616 import * as ServerConfig from "../config.ts";
1717 import * as TextGeneration from "./TextGeneration.ts";
1818 import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";
19+import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
1920 const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2021
2122 const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
2223 const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
2324
24−function shellSingleQuote(value: string): string {
25− return `'${value.replaceAll("'", `'"'"'`)}'`;
26−}
27−
2825 const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
2926 prefix: "t3code-grok-text-generation-test-",
3027 }).pipe(Layer.provideMerge(NodeServices.layer));
3128
3229 function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string {
33− const binDir = NodePath.join(dir, "bin");
34− const grokPath = NodePath.join(binDir, "grok");
35− NodeFS.mkdirSync(binDir, { recursive: true });
36− NodeFS.writeFileSync(
37− grokPath,
38− [
39− "#!/bin/sh",
40− ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
41− 'if [ "$1" != "agent" ] || [ "$2" != "stdio" ]; then',
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;
30+ return writeFakeCli({
31+ directory: NodePath.join(dir, "bin"),
32+ name: "grok",
33+ env,
34+ source: execScriptSource({
35+ scriptPath: mockAgentPath,
36+ expectedArgs: ["agent", "stdio"],
37+ }),
38+ });
5239 }
5340
5441 function withFakeAcpGrok<A, E, R>(