| author | |
| committer | |
| log | 5c6c1d67d82bb81bddaee6d83e54e8aecd96ec7d |
| tree | 480435cb3d1428f4e2ece31a4fb68805521399cc |
| parent | 30f128fababb1fea46041d228b6ea52733d2ee09 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Code <noreply@anthropic.com>12 files changed, 383 insertions(+), 370 deletions(-)
.gitattributes+3| ... | ... | @@ -1,2 +1,5 @@ |
| 1 | 1 | # git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt |
| 2 | 2 | * 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 | 23 | |
| 24 | 24 | import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; |
| 25 | 25 | import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; |
| 26 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 26 | 27 | |
| 27 | 28 | const ROOT = wireFixture.rootThreadId; |
| 28 | 29 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; |
| ... | ... | @@ -157,7 +158,11 @@ function readRecordedRequests() { |
| 157 | 158 | } |
| 158 | 159 | |
| 159 | 160 | 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 | ); | |
| 161 | 166 | |
| 162 | 167 | describe("CodexSessionRuntime collab integration", () => { |
| 163 | 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 | 30 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 31 | 31 | import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; |
| 32 | 32 | import { makeCursorAdapter } from "./CursorAdapter.ts"; |
| 33 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | |
| 34 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 33 | 35 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 34 | 36 | |
| 35 | 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 | 41 | |
| 40 | 42 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 41 | 43 | 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"; | |
| 45 | 47 | async function makeMockAgentWrapper( |
| 46 | 48 | extraEnv?: Record<string, string>, |
| 47 | 49 | options?: { initialDelaySeconds?: number }, |
| 48 | 50 | ) { |
| 49 | 51 | 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 | }); | |
| 62 | 63 | } |
| 63 | 64 | |
| 64 | 65 | async function makeProbeWrapper( |
| ... | ... | @@ -67,20 +68,12 @@ async function makeProbeWrapper( |
| 67 | 68 | extraEnv?: Record<string, string>, |
| 68 | 69 | ) { |
| 69 | 70 | 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 | }); | |
| 84 | 77 | } |
| 85 | 78 | |
| 86 | 79 | async function readArgvLog(filePath: string) { |
| ... | ... | @@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 390 | 383 | }), |
| 391 | 384 | ); |
| 392 | 385 | |
| 393 | it.effect("closes the ACP child process when a session stops", () => | |
| 386 | it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => | |
| 394 | 387 | Effect.gen(function* () { |
| 395 | 388 | const adapter = yield* CursorAdapter; |
| 396 | 389 | const settings = yield* ServerSettingsService; |
| ... | ... | @@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 422 | 415 | }), |
| 423 | 416 | ); |
| 424 | 417 | |
| 425 | it.effect( | |
| 418 | it.effect.skipIf(windowsHost)( | |
| 426 | 419 | "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", |
| 427 | 420 | () => |
| 428 | 421 | Effect.gen(function* () { |
apps/server/src/provider/Layers/CursorProvider.test.ts+25-28| ... | ... | @@ -30,6 +30,8 @@ import { |
| 30 | 30 | probeCursorSkills, |
| 31 | 31 | rewriteCursorSkillMentions, |
| 32 | 32 | } from "../Drivers/CursorSkills.ts"; |
| 33 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | |
| 34 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 33 | 35 | |
| 34 | 36 | const runNode = <A, E>( |
| 35 | 37 | effect: Effect.Effect< |
| ... | ... | @@ -39,6 +41,10 @@ const runNode = <A, E>( |
| 39 | 41 | >, |
| 40 | 42 | ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); |
| 41 | 43 | |
| 44 | // Closing the probe kills the agent with SIGTERM; Windows terminates the | |
| 45 | // process instead, so the mock never sees a signal to log. | |
| 46 | const windowsHost = HostProcessPlatform.defaultValue() === "win32"; | |
| 47 | ||
| 42 | 48 | const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { |
| 43 | 49 | const path = yield* Path.Path; |
| 44 | 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 | 79 | extraEnv?: Record<string, string>, |
| 74 | 80 | ) { |
| 75 | 81 | const fileSystem = yield* FileSystem.FileSystem; |
| 76 | const path = yield* Path.Path; | |
| 77 | 82 | const mockAgentPath = yield* resolveMockAgentPath(); |
| 78 | 83 | const dir = yield* fileSystem.makeTempDirectory({ |
| 79 | 84 | directory: NodeOS.tmpdir(), |
| 80 | 85 | prefix: "cursor-provider-mock-", |
| 81 | 86 | }); |
| 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 | }); | |
| 94 | 93 | }); |
| 95 | 94 | |
| 96 | 95 | const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { |
| 97 | 96 | const fileSystem = yield* FileSystem.FileSystem; |
| 98 | const path = yield* Path.Path; | |
| 99 | 97 | const mockAgentPath = yield* resolveMockAgentPath(); |
| 100 | 98 | const dir = yield* fileSystem.makeTempDirectory({ |
| 101 | 99 | directory: NodeOS.tmpdir(), |
| 102 | 100 | prefix: "cursor-provider-about-mock-", |
| 103 | 101 | }); |
| 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 | }); | |
| 117 | 114 | }); |
| 118 | 115 | |
| 119 | 116 | const waitForFileContent = Effect.fn("waitForFileContent")(function* ( |
| ... | ... | @@ -593,7 +590,7 @@ describe("discoverCursorModelsViaAcp", () => { |
| 593 | 590 | ]); |
| 594 | 591 | }); |
| 595 | 592 | |
| 596 | it("closes the ACP probe runtime after discovery completes", async () => { | |
| 593 | it.skipIf(windowsHost)("closes the ACP probe runtime after discovery completes", async () => { | |
| 597 | 594 | const { exitLogPath, wrapperPath } = await runNode( |
| 598 | 595 | makeExitLogFixture("cursor-provider-exit-log-"), |
| 599 | 596 | ); |
apps/server/src/provider/Layers/GrokAdapter.test.ts+12-13| ... | ... | @@ -33,25 +33,24 @@ import { |
| 33 | 33 | nextGrokPlanModeActive, |
| 34 | 34 | selectGrokPermissionOptionId, |
| 35 | 35 | } from "./GrokAdapter.ts"; |
| 36 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | |
| 37 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 36 | 38 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 37 | 39 | |
| 38 | 40 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 39 | 41 | 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"; | |
| 41 | 45 | |
| 42 | 46 | async function makeMockGrokWrapper(extraEnv?: Record<string, string>) { |
| 43 | 47 | 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 | }); | |
| 55 | 54 | } |
| 56 | 55 | |
| 57 | 56 | function waitForFileContent( |
| ... | ... | @@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 340 | 339 | }), |
| 341 | 340 | ); |
| 342 | 341 | |
| 343 | it.effect("closes the ACP child process when a session stops", () => | |
| 342 | it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => | |
| 344 | 343 | Effect.gen(function* () { |
| 345 | 344 | const threadId = ThreadId.make("grok-stop-session-close"); |
| 346 | 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 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | 7 | import * as Effect from "effect/Effect"; |
| 8 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; | |
| 10 | 9 | import * as Schema from "effect/Schema"; |
| 11 | 10 | import { GrokSettings } from "@t3tools/contracts"; |
| 12 | 11 | |
| ... | ... | @@ -17,6 +16,7 @@ import { |
| 17 | 16 | checkGrokProviderStatus, |
| 18 | 17 | parseGrokModelsCliOutput, |
| 19 | 18 | } from "./GrokProvider.ts"; |
| 19 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | |
| 20 | 20 | |
| 21 | 21 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 22 | 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| ... | ... | @@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { |
| 308 | 308 | const snapshot = yield* Effect.scoped( |
| 309 | 309 | Effect.gen(function* () { |
| 310 | 310 | const fs = yield* FileSystem.FileSystem; |
| 311 | const path = yield* Path.Path; | |
| 312 | 311 | 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 | }); | |
| 319 | 322 | |
| 320 | 323 | return yield* checkGrokProviderStatus( |
| 321 | 324 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), |
| ... | ... | @@ -331,37 +334,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { |
| 331 | 334 | }), |
| 332 | 335 | ); |
| 333 | 336 | |
| 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, | |
| 338 | 338 | // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. |
| 339 | 339 | const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => |
| 340 | 340 | Effect.gen(function* () { |
| 341 | 341 | const fs = yield* FileSystem.FileSystem; |
| 342 | const path = yield* Path.Path; | |
| 343 | 342 | 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);"]), | |
| 360 | 359 | "", |
| 361 | 360 | ].join("\n"), |
| 362 | ); | |
| 363 | yield* fs.chmod(grokPath, 0o755); | |
| 364 | return grokPath; | |
| 361 | }); | |
| 365 | 362 | }); |
| 366 | 363 | |
| 367 | 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 | |
| 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 created+106| ... | ... | @@ -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 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { it } from "@effect/vitest"; |
| 3 | 3 | import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 4 | import { isHostWindows } from "@t3tools/shared/hostProcess"; | |
| 4 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; | |
| 5 | 5 | import { createModelSelection } from "@t3tools/shared/model"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -21,27 +21,26 @@ import { |
| 21 | 21 | import * as TextGeneration from "./TextGeneration.ts"; |
| 22 | 22 | import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; |
| 23 | 23 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; |
| 24 | import { writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 24 | 25 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 25 | 26 | |
| 26 | 27 | const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 27 | 28 | prefix: "t3code-claude-text-generation-test-", |
| 28 | 29 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 29 | 30 | |
| 31 | // The stub behaviour lives in Node so the same implementation runs on Windows, | |
| 32 | // where a shebang file is not executable and would fall through to the real | |
| 33 | // Claude CLI on PATH; `writeFakeCli` picks the launcher shape per host. | |
| 30 | 34 | function makeFakeClaudeBinary(dir: string) { |
| 31 | 35 | return Effect.gen(function* () { |
| 32 | const fs = yield* FileSystem.FileSystem; | |
| 33 | 36 | const path = yield* Path.Path; |
| 34 | const isWindows = yield* isHostWindows; | |
| 37 | const platform = yield* HostProcessPlatform; | |
| 35 | 38 | 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: [ | |
| 45 | 44 | 'const args = process.argv.slice(2).join(" ");', |
| 46 | 45 | "", |
| 47 | 46 | "function fail(message, code) {", |
| ... | ... | @@ -87,24 +86,7 @@ function makeFakeClaudeBinary(dir: string) { |
| 87 | 86 | "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);", |
| 88 | 87 | "", |
| 89 | 88 | ].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 | }); | |
| 108 | 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 | 14 | import * as ServerConfig from "../config.ts"; |
| 15 | 15 | import * as TextGeneration from "./TextGeneration.ts"; |
| 16 | 16 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; |
| 17 | import { writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 17 | 18 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| 18 | 19 | |
| 19 | 20 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( |
| ... | ... | @@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process |
| 25 | 26 | prefix: "t3code-codex-text-generation-test-", |
| 26 | 27 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 27 | 28 | |
| 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 | }); | |
| 44 | 61 | return Effect.gen(function* () { |
| 45 | const fs = yield* FileSystem.FileSystem; | |
| 46 | 62 | 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;", | |
| 171 | 127 | "", |
| 172 | 128 | ].join("\n"), |
| 173 | ); | |
| 174 | yield* fs.chmod(codexPath, 0o755); | |
| 175 | return codexPath; | |
| 129 | }); | |
| 176 | 130 | }); |
| 177 | 131 | } |
| 178 | 132 | |
| 179 | 133 | 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 & { | |
| 192 | 135 | launchArgs?: string; |
| 193 | 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 | 11 | import * as Exit from "effect/Exit"; |
| 12 | 12 | import * as Layer from "effect/Layer"; |
| 13 | 13 | import * as Schema from "effect/Schema"; |
| 14 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 14 | 15 | import { createModelSelection } from "@t3tools/shared/model"; |
| 15 | 16 | import { expect } from "vite-plus/test"; |
| 16 | 17 | |
| ... | ... | @@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 19 | 20 | import * as ServerConfig from "../config.ts"; |
| 20 | 21 | import * as TextGeneration from "./TextGeneration.ts"; |
| 21 | 22 | import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; |
| 23 | import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 22 | 24 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 23 | 25 | |
| 24 | 26 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 25 | 27 | const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); |
| 26 | 28 | |
| 27 | function shellSingleQuote(value: string): string { | |
| 28 | return `'${value.replaceAll("'", `'"'"'`)}'`; | |
| 29 | } | |
| 30 | ||
| 31 | 29 | const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 32 | 30 | prefix: "t3code-cursor-text-generation-test-", |
| 33 | 31 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 34 | 32 | |
| 35 | 33 | 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 | }); | |
| 55 | 43 | } |
| 56 | 44 | |
| 57 | 45 | function withFakeAcpAgent<A, E, R>( |
| ... | ... | @@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { |
| 236 | 224 | ), |
| 237 | 225 | ); |
| 238 | 226 | |
| 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"); | |
| 244 | 236 | |
| 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 | }); | |
| 266 | 258 | |
| 267 | expect(generated.subject).toBe("Close runtime after generation"); | |
| 259 | expect(generated.subject).toBe("Close runtime after generation"); | |
| 268 | 260 | |
| 269 | const exitLog = yield* waitForFileContent(exitLogPath); | |
| 270 | expect(exitLog).toContain("exit:0"); | |
| 261 | const exitLog = yield* waitForFileContent(exitLogPath); | |
| 262 | expect(exitLog).toContain("exit:0"); | |
| 271 | 263 | |
| 272 | NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); | |
| 273 | }), | |
| 274 | ); | |
| 275 | }); | |
| 264 | NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); | |
| 265 | }), | |
| 266 | ); | |
| 267 | }, | |
| 268 | ); | |
| 276 | 269 | }); |
apps/server/src/textGeneration/GrokTextGeneration.test.ts+10-23| ... | ... | @@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 16 | 16 | import * as ServerConfig from "../config.ts"; |
| 17 | 17 | import * as TextGeneration from "./TextGeneration.ts"; |
| 18 | 18 | import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; |
| 19 | import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 19 | 20 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 20 | 21 | |
| 21 | 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 22 | 23 | const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); |
| 23 | 24 | |
| 24 | function shellSingleQuote(value: string): string { | |
| 25 | return `'${value.replaceAll("'", `'"'"'`)}'`; | |
| 26 | } | |
| 27 | ||
| 28 | 25 | const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 29 | 26 | prefix: "t3code-grok-text-generation-test-", |
| 30 | 27 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 31 | 28 | |
| 32 | 29 | 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 | }); | |
| 52 | 39 | } |
| 53 | 40 | |
| 54 | 41 | function withFakeAcpGrok<A, E, R>( |