authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 13:33:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 20:33:20+00:00
log5c6c1d67d82bb81bddaee6d83e54e8aecd96ec7d
tree480435cb3d1428f4e2ece31a4fb68805521399cc
parent30f128fababb1fea46041d228b6ea52733d2ee09
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Code <noreply@anthropic.com>

12 files changed, 383 insertions(+), 370 deletions(-)

.gitattributes+3
......@@ -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";
2323
2424import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" };
2525import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";
26import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2627
2728const ROOT = wireFixture.rootThreadId;
2829const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];
......@@ -157,7 +158,11 @@ function readRecordedRequests() {
157158}
158159
159160const 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);
161166
162167describe("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";
3030import { ServerSettingsService } from "../../serverSettings.ts";
3131import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
3232import { makeCursorAdapter } from "./CursorAdapter.ts";
33import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
34import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3335const 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>()
3941
4042const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
4143const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
42const mockAgentCommand = "node";
43const 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.
46const windowsHost = HostProcessPlatform.defaultValue() === "win32";
4547async 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))}` : ""}
57exec ${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
6465async function makeProbeWrapper(
......@@ -67,20 +68,12 @@ async function makeProbeWrapper(
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
75printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)}
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;
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
8679async function readArgvLog(filePath: string) {
......@@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
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) => {
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 {
3030 probeCursorSkills,
3131 rewriteCursorSkillMentions,
3232} from "../Drivers/CursorSkills.ts";
33import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
34import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3335
3436const runNode = <A, E>(
3537 effect: Effect.Effect<
......@@ -39,6 +41,10 @@ const runNode = <A, E>(
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.
46const windowsHost = HostProcessPlatform.defaultValue() === "win32";
47
4248const 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* (
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}
89exec ${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
9695const 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
107if [ "$1" = "about" ]; then
108 printf 'CLI Version 2026.04.09-f2b0fcd\\n'
109 printf 'User Email cursor@example.com\\n'
110 exit 0
111fi
112exec ${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
119116const waitForFileContent = Effect.fn("waitForFileContent")(function* (
......@@ -593,7 +590,7 @@ describe("discoverCursorModelsViaAcp", () => {
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 {
3333 nextGrokPlanModeActive,
3434 selectGrokPermissionOptionId,
3535} from "./GrokAdapter.ts";
36import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
37import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3638const decodeGrokSettings = Schema.decodeSync(GrokSettings);
3739
3840const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
3941const 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";
4145
4246async 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}
50exec ${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
5756function waitForFileContent(
......@@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
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";
66import { describe, expect, it } from "@effect/vitest";
77import * as Effect from "effect/Effect";
88import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
109import * as Schema from "effect/Schema";
1110import { GrokSettings } from "@t3tools/contracts";
1211
......@@ -17,6 +16,7 @@ import {
1716 checkGrokProviderStatus,
1817 parseGrokModelsCliOutput,
1918} from "./GrokProvider.ts";
19import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
2020
2121const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2222const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
......@@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
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) => {
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 created+8
......@@ -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 created+106
......@@ -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 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { it } from "@effect/vitest";
33import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
4import { isHostWindows } from "@t3tools/shared/hostProcess";
4import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
55import { createModelSelection } from "@t3tools/shared/model";
66import * as Effect from "effect/Effect";
77import * as FileSystem from "effect/FileSystem";
......@@ -21,27 +21,26 @@ import {
2121import * as TextGeneration from "./TextGeneration.ts";
2222import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";
2323import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
24import { writeFakeCli } from "../testUtils/fakeCli.ts";
2425const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
2526
2627const 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.
3034function 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) {
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
1414import * as ServerConfig from "../config.ts";
1515import * as TextGeneration from "./TextGeneration.ts";
1616import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
17import { writeFakeCli } from "../testUtils/fakeCli.ts";
1718const decodeCodexSettings = Schema.decodeSync(CodexSettings);
1819
1920const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
......@@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process
2526 prefix: "t3code-codex-text-generation-test-",
2627}).pipe(Layer.provideMerge(NodeServices.layer));
2728
28function 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) {
29interface 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.
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 });
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
179133function 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";
1111import * as Exit from "effect/Exit";
1212import * as Layer from "effect/Layer";
1313import * as Schema from "effect/Schema";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1415import { createModelSelection } from "@t3tools/shared/model";
1516import { expect } from "vite-plus/test";
1617
......@@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";
1920import * as ServerConfig from "../config.ts";
2021import * as TextGeneration from "./TextGeneration.ts";
2122import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
23import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
2224const decodeCursorSettings = Schema.decodeSync(CursorSettings);
2325
2426const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
2527const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
2628
27function shellSingleQuote(value: string): string {
28 return `'${value.replaceAll("'", `'"'"'`)}'`;
29}
30
3129const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
3230 prefix: "t3code-cursor-text-generation-test-",
3331}).pipe(Layer.provideMerge(NodeServices.layer));
3432
3533function 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
5745function withFakeAcpAgent<A, E, R>(
......@@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => {
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";
1616import * as ServerConfig from "../config.ts";
1717import * as TextGeneration from "./TextGeneration.ts";
1818import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";
19import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
1920const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2021
2122const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
2223const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
2324
24function shellSingleQuote(value: string): string {
25 return `'${value.replaceAll("'", `'"'"'`)}'`;
26}
27
2825const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
2926 prefix: "t3code-grok-text-generation-test-",
3027}).pipe(Layer.provideMerge(NodeServices.layer));
3128
3229function 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
5441function withFakeAcpGrok<A, E, R>(