Unified · Split
| @@ -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 | |
| @@ -23,6 +23,7 @@ import { assert, describe } from "vite-plus/test"; · 22 unmodified lines | ||
| 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() { · 128 unmodified lines | ||
| 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", () => |
| @@ -30,6 +30,8 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; · 29 unmodified lines | ||
| 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>() · 3 unmodified lines | ||
| 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( · 2 unmodified lines | ||
| 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) => { · 303 unmodified lines | ||
| 390 | 383 | }), |
| 391 | 384 | ); |
| 392 | 385 | |
| 393 | − it.effect("closes the ACP child process when a session stops", () => | |
| 386 | + it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => | |
| 394 | 387 | Effect.gen(function* () { |
| 395 | 388 | const adapter = yield* CursorAdapter; |
| 396 | 389 | const settings = yield* ServerSettingsService; |
| @@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { · 25 unmodified lines | ||
| 422 | 415 | }), |
| 423 | 416 | ); |
| 424 | 417 | |
| 425 | − it.effect( | |
| 418 | + it.effect.skipIf(windowsHost)( | |
| 426 | 419 | "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", |
| 427 | 420 | () => |
| 428 | 421 | Effect.gen(function* () { |
| @@ -30,6 +30,8 @@ import { · 29 unmodified lines | ||
| 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>( · 3 unmodified lines | ||
| 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* ( · 28 unmodified lines | ||
| 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", () => { · 473 unmodified lines | ||
| 593 | 590 | ]); |
| 594 | 591 | }); |
| 595 | 592 | |
| 596 | − it("closes the ACP probe runtime after discovery completes", async () => { | |
| 593 | + it.skipIf(windowsHost)("closes the ACP probe runtime after discovery completes", async () => { | |
| 597 | 594 | const { exitLogPath, wrapperPath } = await runNode( |
| 598 | 595 | makeExitLogFixture("cursor-provider-exit-log-"), |
| 599 | 596 | ); |
| @@ -33,25 +33,24 @@ import { · 32 unmodified lines | ||
| 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) => { · 282 unmodified lines | ||
| 340 | 339 | }), |
| 341 | 340 | ); |
| 342 | 341 | |
| 343 | − it.effect("closes the ACP child process when a session stops", () => | |
| 342 | + it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => | |
| 344 | 343 | Effect.gen(function* () { |
| 345 | 344 | const threadId = ThreadId.make("grok-stop-session-close"); |
| 346 | 345 | const tempDir = yield* Effect.promise(() => |
| @@ -6,7 +6,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 5 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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) => { · 285 unmodified lines | ||
| 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) => { · 9 unmodified lines | ||
| 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", () => |
| @@ -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% | |
| @@ -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 | +} | |
| @@ -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 { · 13 unmodified lines | ||
| 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) { · 39 unmodified lines | ||
| 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 | } |
| @@ -14,6 +14,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools · 13 unmodified lines | ||
| 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 · 5 unmodified lines | ||
| 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 | }, |
| @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; · 10 unmodified lines | ||
| 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"; · 2 unmodified lines | ||
| 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) => { · 178 unmodified lines | ||
| 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 | }); |
| @@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; · 15 unmodified lines | ||
| 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>( |