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 @@...@@ -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";...@@ -23,6 +23,7 @@ import { assert, describe } from "vite-plus/test";
2323
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";
2627
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() {...@@ -157,7 +158,11 @@ function readRecordedRequests() {
157}158}
158159
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);
161166
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";...@@ -30,6 +30,8 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
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);
3436
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>()...@@ -39,26 +41,25 @@ class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()
3941
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.
4446const 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}
6364
64async function makeProbeWrapper(65async function makeProbeWrapper(
...@@ -67,20 +68,12 @@ async function makeProbeWrapper(...@@ -67,20 +68,12 @@ async function makeProbeWrapper(
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}
8578
86async function readArgvLog(filePath: string) {79async function readArgvLog(filePath: string) {
...@@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
390 }),383 }),
391 );384 );
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", () =>
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) => {...@@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
422 }),415 }),
423 );416 );
424417
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 {...@@ -30,6 +30,8 @@ import {
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";
3335
34const runNode = <A, E>(36const runNode = <A, E>(
35 effect: Effect.Effect<37 effect: Effect.Effect<
...@@ -39,6 +41,10 @@ const runNode = <A, E>(...@@ -39,6 +41,10 @@ const runNode = <A, E>(
39 >,41 >,
40): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));42): 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
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* (...@@ -73,47 +79,38 @@ const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* (
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});
9594
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});
118115
119const waitForFileContent = Effect.fn("waitForFileContent")(function* (116const waitForFileContent = Effect.fn("waitForFileContent")(function* (
...@@ -593,7 +590,7 @@ describe("discoverCursorModelsViaAcp", () => {...@@ -593,7 +590,7 @@ describe("discoverCursorModelsViaAcp", () => {
593 ]);590 ]);
594 });591 });
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 () => {
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 {...@@ -33,25 +33,24 @@ import {
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);
3739
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";
4145
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}
5655
57function waitForFileContent(56function waitForFileContent(
...@@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
340 }),339 }),
341 );340 );
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", () =>
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";...@@ -6,7 +6,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
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";
1211
...@@ -17,6 +16,7 @@ import {...@@ -17,6 +16,7 @@ import {
17 checkGrokProviderStatus,16 checkGrokProviderStatus,
18 parseGrokModelsCliOutput,17 parseGrokModelsCliOutput,
19} from "./GrokProvider.ts";18} from "./GrokProvider.ts";
19import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
2020
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) => {...@@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
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 });
319322
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) => {...@@ -331,37 +334,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
331 }),334 }),
332 );335 );
333336
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 });
366363
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 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 @@...@@ -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 {...@@ -21,27 +21,26 @@ import {
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);
2526
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));
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.
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,
3841 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) {...@@ -87,24 +86,7 @@ function makeFakeClaudeBinary(dir: string) {
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...@@ -14,6 +14,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools
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);
1819
19const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(20const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
...@@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process...@@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process
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));
2728
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",
5066 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}
178132
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";...@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";
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";
1617
...@@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";...@@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";
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);
2325
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");
2628
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));
3432
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}
5644
57function withFakeAcpAgent<A, E, R>(45function withFakeAcpAgent<A, E, R>(
...@@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => {...@@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => {
236 ),224 ),
237 );225 );
238226
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");
244236
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 });
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);261 const exitLog = yield* waitForFileContent(exitLogPath);
270 expect(exitLog).toContain("exit:0");262 expect(exitLog).toContain("exit:0");
271263
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";...@@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts";
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);
2021
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");
2324
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));
3128
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}
5340
54function withFakeAcpGrok<A, E, R>(41function withFakeAcpGrok<A, E, R>(