| 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,2 +1,5 @@ |
| 1 | # git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt | 1 | # git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt |
| 2 | * text=auto eol=lf | 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,6 +23,7 @@ import { assert, describe } from "vite-plus/test"; |
| 23 | 23 | ||
| 24 | import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; | 24 | import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; |
| 25 | import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; | 25 | import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; |
| 26 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 26 | 27 | ||
| 27 | const ROOT = wireFixture.rootThreadId; | 28 | const ROOT = wireFixture.rootThreadId; |
| 28 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; | 29 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; |
| ... | @@ -157,7 +158,11 @@ function readRecordedRequests() { | ... | @@ -157,7 +158,11 @@ function readRecordedRequests() { |
| 157 | } | 158 | } |
| 158 | 159 | ||
| 159 | const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); | 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 | describe("CodexSessionRuntime collab integration", () => { | 167 | describe("CodexSessionRuntime collab integration", () => { |
| 163 | it.effect("looks up child model metadata once after activity registration", () => | 168 | it.effect("looks up child model metadata once after activity registration", () => |
apps/server/src/provider/Layers/CursorAdapter.test.ts+24-31| ... | @@ -30,6 +30,8 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; | ... | @@ -30,6 +30,8 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; |
| 30 | import { ServerSettingsService } from "../../serverSettings.ts"; | 30 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 31 | import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; | 31 | import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; |
| 32 | import { makeCursorAdapter } from "./CursorAdapter.ts"; | 32 | import { makeCursorAdapter } from "./CursorAdapter.ts"; |
| 33 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | ||
| 34 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 33 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | 35 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 34 | 36 | ||
| 35 | // Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. | 37 | // Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. |
| ... | @@ -39,26 +41,25 @@ class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>() | ... | @@ -39,26 +41,25 @@ class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>() |
| 39 | 41 | ||
| 40 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 42 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 41 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | 43 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); |
| 42 | const mockAgentCommand = "node"; | 44 | // Stopping a session kills the agent with SIGTERM; Windows terminates the |
| 43 | const mockAgentArgs = [mockAgentPath] as const; | 45 | // process instead, so the mock never sees a signal to log. |
| 44 | 46 | const windowsHost = HostProcessPlatform.defaultValue() === "win32"; | |
| 45 | async function makeMockAgentWrapper( | 47 | async function makeMockAgentWrapper( |
| 46 | extraEnv?: Record<string, string>, | 48 | extraEnv?: Record<string, string>, |
| 47 | options?: { initialDelaySeconds?: number }, | 49 | options?: { initialDelaySeconds?: number }, |
| 48 | ) { | 50 | ) { |
| 49 | const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-")); | 51 | const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-")); |
| 50 | const wrapperPath = NodePath.join(dir, "fake-agent.sh"); | 52 | return writeFakeCli({ |
| 51 | const envExports = Object.entries(extraEnv ?? {}) | 53 | directory: dir, |
| 52 | .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) | 54 | name: "fake-agent", |
| 53 | .join("\n"); | 55 | env: extraEnv ?? {}, |
| 54 | const script = `#!/bin/sh | 56 | source: execScriptSource({ |
| 55 | ${envExports} | 57 | scriptPath: mockAgentPath, |
| 56 | ${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} | 58 | ...(options?.initialDelaySeconds === undefined |
| 57 | exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" | 59 | ? {} |
| 58 | `; | 60 | : { delayMs: Math.round(options.initialDelaySeconds * 1000) }), |
| 59 | await NodeFSP.writeFile(wrapperPath, script, "utf8"); | 61 | }), |
| 60 | await NodeFSP.chmod(wrapperPath, 0o755); | 62 | }); |
| 61 | return wrapperPath; | ||
| 62 | } | 63 | } |
| 63 | 64 | ||
| 64 | async function makeProbeWrapper( | 65 | async function makeProbeWrapper( |
| ... | @@ -67,20 +68,12 @@ async function makeProbeWrapper( | ... | @@ -67,20 +68,12 @@ async function makeProbeWrapper( |
| 67 | extraEnv?: Record<string, string>, | 68 | extraEnv?: Record<string, string>, |
| 68 | ) { | 69 | ) { |
| 69 | const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-")); | 70 | const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-")); |
| 70 | const wrapperPath = NodePath.join(dir, "fake-agent.sh"); | 71 | return writeFakeCli({ |
| 71 | const envExports = Object.entries(extraEnv ?? {}) | 72 | directory: dir, |
| 72 | .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) | 73 | name: "fake-agent", |
| 73 | .join("\n"); | 74 | env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv }, |
| 74 | const script = `#!/bin/sh | 75 | source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), |
| 75 | printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} | 76 | }); |
| 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; | ||
| 84 | } | 77 | } |
| 85 | 78 | ||
| 86 | async function readArgvLog(filePath: string) { | 79 | async function readArgvLog(filePath: string) { |
| ... | @@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -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 | Effect.gen(function* () { | 387 | Effect.gen(function* () { |
| 395 | const adapter = yield* CursorAdapter; | 388 | const adapter = yield* CursorAdapter; |
| 396 | const settings = yield* ServerSettingsService; | 389 | const settings = yield* ServerSettingsService; |
| ... | @@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 422 | }), | 415 | }), |
| 423 | ); | 416 | ); |
| 424 | 417 | ||
| 425 | it.effect( | 418 | it.effect.skipIf(windowsHost)( |
| 426 | "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", | 419 | "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", |
| 427 | () => | 420 | () => |
| 428 | Effect.gen(function* () { | 421 | Effect.gen(function* () { |
apps/server/src/provider/Layers/CursorProvider.test.ts+25-28| ... | @@ -30,6 +30,8 @@ import { | ... | @@ -30,6 +30,8 @@ import { |
| 30 | probeCursorSkills, | 30 | probeCursorSkills, |
| 31 | rewriteCursorSkillMentions, | 31 | rewriteCursorSkillMentions, |
| 32 | } from "../Drivers/CursorSkills.ts"; | 32 | } from "../Drivers/CursorSkills.ts"; |
| 33 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | ||
| 34 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 33 | 35 | ||
| 34 | const runNode = <A, E>( | 36 | const runNode = <A, E>( |
| 35 | effect: Effect.Effect< | 37 | effect: Effect.Effect< |
| ... | @@ -39,6 +41,10 @@ const runNode = <A, E>( | ... | @@ -39,6 +41,10 @@ const runNode = <A, E>( |
| 39 | >, | 41 | >, |
| 40 | ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); | 42 | ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); |
| 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 | const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { | 48 | const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { |
| 43 | const path = yield* Path.Path; | 49 | const path = yield* Path.Path; |
| 44 | return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); | 50 | return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); |
| ... | @@ -73,47 +79,38 @@ const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( | ... | @@ -73,47 +79,38 @@ const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( |
| 73 | extraEnv?: Record<string, string>, | 79 | extraEnv?: Record<string, string>, |
| 74 | ) { | 80 | ) { |
| 75 | const fileSystem = yield* FileSystem.FileSystem; | 81 | const fileSystem = yield* FileSystem.FileSystem; |
| 76 | const path = yield* Path.Path; | ||
| 77 | const mockAgentPath = yield* resolveMockAgentPath(); | 82 | const mockAgentPath = yield* resolveMockAgentPath(); |
| 78 | const dir = yield* fileSystem.makeTempDirectory({ | 83 | const dir = yield* fileSystem.makeTempDirectory({ |
| 79 | directory: NodeOS.tmpdir(), | 84 | directory: NodeOS.tmpdir(), |
| 80 | prefix: "cursor-provider-mock-", | 85 | prefix: "cursor-provider-mock-", |
| 81 | }); | 86 | }); |
| 82 | const wrapperPath = path.join(dir, "fake-agent.sh"); | 87 | return writeFakeCli({ |
| 83 | const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); | 88 | directory: dir, |
| 84 | const envExports = Object.entries(extraEnv ?? {}) | 89 | name: "fake-agent", |
| 85 | .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) | 90 | env: extraEnv ?? {}, |
| 86 | .join("\n"); | 91 | source: execScriptSource({ scriptPath: mockAgentPath }), |
| 87 | const script = `#!/bin/sh | 92 | }); |
| 88 | ${envExports} | ||
| 89 | exec ${mockAgentCommand} "$@" | ||
| 90 | `; | ||
| 91 | yield* fileSystem.writeFileString(wrapperPath, script); | ||
| 92 | yield* fileSystem.chmod(wrapperPath, 0o755); | ||
| 93 | return wrapperPath; | ||
| 94 | }); | 93 | }); |
| 95 | 94 | ||
| 96 | const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { | 95 | const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { |
| 97 | const fileSystem = yield* FileSystem.FileSystem; | 96 | const fileSystem = yield* FileSystem.FileSystem; |
| 98 | const path = yield* Path.Path; | ||
| 99 | const mockAgentPath = yield* resolveMockAgentPath(); | 97 | const mockAgentPath = yield* resolveMockAgentPath(); |
| 100 | const dir = yield* fileSystem.makeTempDirectory({ | 98 | const dir = yield* fileSystem.makeTempDirectory({ |
| 101 | directory: NodeOS.tmpdir(), | 99 | directory: NodeOS.tmpdir(), |
| 102 | prefix: "cursor-provider-about-mock-", | 100 | prefix: "cursor-provider-about-mock-", |
| 103 | }); | 101 | }); |
| 104 | const wrapperPath = path.join(dir, "fake-agent.sh"); | 102 | return writeFakeCli({ |
| 105 | const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); | 103 | directory: dir, |
| 106 | const script = `#!/bin/sh | 104 | name: "fake-agent", |
| 107 | if [ "$1" = "about" ]; then | 105 | source: [ |
| 108 | printf 'CLI Version 2026.04.09-f2b0fcd\\n' | 106 | 'if (process.argv[2] === "about") {', |
| 109 | printf 'User Email cursor@example.com\\n' | 107 | ' process.stdout.write("CLI Version 2026.04.09-f2b0fcd\\n");', |
| 110 | exit 0 | 108 | ' process.stdout.write("User Email cursor@example.com\\n");', |
| 111 | fi | 109 | " process.exit(0);", |
| 112 | exec ${mockAgentCommand} "$@" | 110 | "}", |
| 113 | `; | 111 | execScriptSource({ scriptPath: mockAgentPath }), |
| 114 | yield* fileSystem.writeFileString(wrapperPath, script); | 112 | ].join("\n"), |
| 115 | yield* fileSystem.chmod(wrapperPath, 0o755); | 113 | }); |
| 116 | return wrapperPath; | ||
| 117 | }); | 114 | }); |
| 118 | 115 | ||
| 119 | const waitForFileContent = Effect.fn("waitForFileContent")(function* ( | 116 | const waitForFileContent = Effect.fn("waitForFileContent")(function* ( |
| ... | @@ -593,7 +590,7 @@ describe("discoverCursorModelsViaAcp", () => { | ... | @@ -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 | const { exitLogPath, wrapperPath } = await runNode( | 594 | const { exitLogPath, wrapperPath } = await runNode( |
| 598 | makeExitLogFixture("cursor-provider-exit-log-"), | 595 | makeExitLogFixture("cursor-provider-exit-log-"), |
| 599 | ); | 596 | ); |
apps/server/src/provider/Layers/GrokAdapter.test.ts+12-13| ... | @@ -33,25 +33,24 @@ import { | ... | @@ -33,25 +33,24 @@ import { |
| 33 | nextGrokPlanModeActive, | 33 | nextGrokPlanModeActive, |
| 34 | selectGrokPermissionOptionId, | 34 | selectGrokPermissionOptionId, |
| 35 | } from "./GrokAdapter.ts"; | 35 | } from "./GrokAdapter.ts"; |
| 36 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | ||
| 37 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 36 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 38 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 37 | 39 | ||
| 38 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 40 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 39 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | 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 | async function makeMockGrokWrapper(extraEnv?: Record<string, string>) { | 46 | async function makeMockGrokWrapper(extraEnv?: Record<string, string>) { |
| 43 | const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); | 47 | const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); |
| 44 | const wrapperPath = NodePath.join(dir, "fake-grok.sh"); | 48 | return writeFakeCli({ |
| 45 | const envExports = Object.entries(extraEnv ?? {}) | 49 | directory: dir, |
| 46 | .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) | 50 | name: "fake-grok", |
| 47 | .join("\n"); | 51 | env: extraEnv ?? {}, |
| 48 | const script = `#!/bin/sh | 52 | source: execScriptSource({ scriptPath: mockAgentPath }), |
| 49 | ${envExports} | 53 | }); |
| 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; | ||
| 55 | } | 54 | } |
| 56 | 55 | ||
| 57 | function waitForFileContent( | 56 | function waitForFileContent( |
| ... | @@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -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 | Effect.gen(function* () { | 343 | Effect.gen(function* () { |
| 345 | const threadId = ThreadId.make("grok-stop-session-close"); | 344 | const threadId = ThreadId.make("grok-stop-session-close"); |
| 346 | const tempDir = yield* Effect.promise(() => | 345 | const tempDir = yield* Effect.promise(() => |
apps/server/src/provider/Layers/GrokProvider.test.ts+29-32| ... | @@ -6,7 +6,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -6,7 +6,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 11 | import { GrokSettings } from "@t3tools/contracts"; | 10 | import { GrokSettings } from "@t3tools/contracts"; |
| 12 | 11 | ||
| ... | @@ -17,6 +16,7 @@ import { | ... | @@ -17,6 +16,7 @@ import { |
| 17 | checkGrokProviderStatus, | 16 | checkGrokProviderStatus, |
| 18 | parseGrokModelsCliOutput, | 17 | parseGrokModelsCliOutput, |
| 19 | } from "./GrokProvider.ts"; | 18 | } from "./GrokProvider.ts"; |
| 19 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | ||
| 20 | 20 | ||
| 21 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 21 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| ... | @@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { | ... | @@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { |
| 308 | const snapshot = yield* Effect.scoped( | 308 | const snapshot = yield* Effect.scoped( |
| 309 | Effect.gen(function* () { | 309 | Effect.gen(function* () { |
| 310 | const fs = yield* FileSystem.FileSystem; | 310 | const fs = yield* FileSystem.FileSystem; |
| 311 | const path = yield* Path.Path; | ||
| 312 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); | 311 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); |
| 313 | const grokPath = path.join(dir, "grok"); | 312 | const grokPath = writeFakeCli({ |
| 314 | yield* fs.writeFileString( | 313 | directory: dir, |
| 315 | grokPath, | 314 | name: "grok", |
| 316 | ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), | 315 | source: [ |
| 317 | ); | 316 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 318 | yield* fs.chmod(grokPath, 0o755); | 317 | `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, |
| 318 | "process.exit(2);", | ||
| 319 | "", | ||
| 320 | ].join("\n"), | ||
| 321 | }); | ||
| 319 | 322 | ||
| 320 | return yield* checkGrokProviderStatus( | 323 | return yield* checkGrokProviderStatus( |
| 321 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | 324 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), |
| ... | @@ -331,37 +334,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { | ... | @@ -331,37 +334,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { |
| 331 | }), | 334 | }), |
| 332 | ); | 335 | ); |
| 333 | 336 | ||
| 334 | // Single-quotes a path for /bin/sh. Temp dirs and execPath never contain quotes. | 337 | // A stand-in for the Grok CLI: `--version` and `models` print canned text, |
| 335 | const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; | ||
| 336 | |||
| 337 | // A shell stand-in for the Grok CLI: `--version` and `models` print canned text, | ||
| 338 | // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. | 338 | // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. |
| 339 | const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => | 339 | const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => |
| 340 | Effect.gen(function* () { | 340 | Effect.gen(function* () { |
| 341 | const fs = yield* FileSystem.FileSystem; | 341 | const fs = yield* FileSystem.FileSystem; |
| 342 | const path = yield* Path.Path; | ||
| 343 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); | 342 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); |
| 344 | const modelsPath = path.join(dir, "models.txt"); | 343 | const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); |
| 345 | yield* fs.writeFileString(modelsPath, input.modelsOutput); | 344 | return writeFakeCli({ |
| 346 | const grokPath = path.join(dir, "grok"); | 345 | directory: dir, |
| 347 | const mockAgentPath = path.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); | 346 | name: "grok", |
| 348 | yield* fs.writeFileString( | 347 | source: [ |
| 349 | grokPath, | 348 | 'if (process.argv[2] === "--version") {', |
| 350 | [ | 349 | ' process.stdout.write("grok 1.0.13\\n");', |
| 351 | "#!/bin/sh", | 350 | " process.exit(0);", |
| 352 | 'case "$1" in', | 351 | "}", |
| 353 | ' --version) printf "grok 1.0.13\\n"; exit 0;;', | 352 | 'if (process.argv[2] === "models") {', |
| 354 | ` models) cat ${shellQuote(modelsPath)}; exit 0;;`, | 353 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 355 | input.acp | 354 | ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, |
| 356 | ? ` agent) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;` | 355 | " process.exit(0);", |
| 357 | : " agent) exit 3;;", | 356 | "}", |
| 358 | "esac", | 357 | 'if (process.argv[2] !== "agent") process.exit(1);', |
| 359 | "exit 1", | 358 | ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), |
| 360 | "", | 359 | "", |
| 361 | ].join("\n"), | 360 | ].join("\n"), |
| 362 | ); | 361 | }); |
| 363 | yield* fs.chmod(grokPath, 0o755); | ||
| 364 | return grokPath; | ||
| 365 | }); | 362 | }); |
| 366 | 363 | ||
| 367 | it.effect("reports ready with ACP-discovered models when logged in", () => | 364 | it.effect("reports ready with ACP-discovered models when logged in", () => |
apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | @echo off | ||
| 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,7 +1,7 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it } from "@effect/vitest"; | 2 | import { it } from "@effect/vitest"; |
| 3 | import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; | 3 | import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 4 | import { isHostWindows } from "@t3tools/shared/hostProcess"; | 4 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; |
| 5 | import { createModelSelection } from "@t3tools/shared/model"; | 5 | import { createModelSelection } from "@t3tools/shared/model"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -21,27 +21,26 @@ import { | ... | @@ -21,27 +21,26 @@ import { |
| 21 | import * as TextGeneration from "./TextGeneration.ts"; | 21 | import * as TextGeneration from "./TextGeneration.ts"; |
| 22 | import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; | 22 | import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; |
| 23 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; | 23 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; |
| 24 | import { writeFakeCli } from "../testUtils/fakeCli.ts"; | ||
| 24 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); | 25 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 25 | 26 | ||
| 26 | const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { | 27 | const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 27 | prefix: "t3code-claude-text-generation-test-", | 28 | prefix: "t3code-claude-text-generation-test-", |
| 28 | }).pipe(Layer.provideMerge(NodeServices.layer)); | 29 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 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 | function makeFakeClaudeBinary(dir: string) { | 34 | function makeFakeClaudeBinary(dir: string) { |
| 31 | return Effect.gen(function* () { | 35 | return Effect.gen(function* () { |
| 32 | const fs = yield* FileSystem.FileSystem; | ||
| 33 | const path = yield* Path.Path; | 36 | const path = yield* Path.Path; |
| 34 | const isWindows = yield* isHostWindows; | 37 | const platform = yield* HostProcessPlatform; |
| 35 | const binDir = path.join(dir, "bin"); | 38 | const binDir = path.join(dir, "bin"); |
| 36 | const stubPath = path.join(binDir, "claude-stub.mjs"); | 39 | writeFakeCli({ |
| 37 | yield* fs.makeDirectory(binDir, { recursive: true }); | 40 | directory: binDir, |
| 38 | 41 | name: "claude", | |
| 39 | // The stub behaviour lives in Node rather than a `#!/bin/sh` script so the | 42 | platform, |
| 40 | // same implementation is usable on Windows, where a shebang file is not | 43 | source: [ |
| 41 | // executable and would fall through to the real Claude CLI on PATH. | ||
| 42 | yield* fs.writeFileString( | ||
| 43 | stubPath, | ||
| 44 | [ | ||
| 45 | 'const args = process.argv.slice(2).join(" ");', | 44 | 'const args = process.argv.slice(2).join(" ");', |
| 46 | "", | 45 | "", |
| 47 | "function fail(message, code) {", | 46 | "function fail(message, code) {", |
| ... | @@ -87,24 +86,7 @@ function makeFakeClaudeBinary(dir: string) { | ... | @@ -87,24 +86,7 @@ function makeFakeClaudeBinary(dir: string) { |
| 87 | "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);", | 86 | "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);", |
| 88 | "", | 87 | "", |
| 89 | ].join("\n"), | 88 | ].join("\n"), |
| 90 | ); | 89 | }); |
| 91 | |||
| 92 | if (isWindows) { | ||
| 93 | // Windows resolves executables through PATHEXT, so the entry point has to | ||
| 94 | // carry a real extension. `resolveSpawnCommand` spawns `.cmd` via a shell. | ||
| 95 | yield* fs.writeFileString( | ||
| 96 | path.join(binDir, "claude.cmd"), | ||
| 97 | ["@echo off", 'node "%~dp0claude-stub.mjs" %*', "exit /b %ERRORLEVEL%", ""].join("\r\n"), | ||
| 98 | ); | ||
| 99 | } else { | ||
| 100 | const claudePath = path.join(binDir, "claude"); | ||
| 101 | yield* fs.writeFileString( | ||
| 102 | claudePath, | ||
| 103 | ["#!/bin/sh", 'exec node "$(dirname "$0")/claude-stub.mjs" "$@"', ""].join("\n"), | ||
| 104 | ); | ||
| 105 | yield* fs.chmod(claudePath, 0o755); | ||
| 106 | } | ||
| 107 | |||
| 108 | return binDir; | 90 | return binDir; |
| 109 | }); | 91 | }); |
| 110 | } | 92 | } |
apps/server/src/textGeneration/CodexTextGeneration.test.ts+99-156| ... | @@ -14,6 +14,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools | ... | @@ -14,6 +14,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools |
| 14 | import * as ServerConfig from "../config.ts"; | 14 | import * as ServerConfig from "../config.ts"; |
| 15 | import * as TextGeneration from "./TextGeneration.ts"; | 15 | import * as TextGeneration from "./TextGeneration.ts"; |
| 16 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; | 16 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; |
| 17 | import { writeFakeCli } from "../testUtils/fakeCli.ts"; | ||
| 17 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); | 18 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| 18 | 19 | ||
| 19 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( | 20 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( |
| ... | @@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process | ... | @@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process |
| 25 | prefix: "t3code-codex-text-generation-test-", | 26 | prefix: "t3code-codex-text-generation-test-", |
| 26 | }).pipe(Layer.provideMerge(NodeServices.layer)); | 27 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 27 | 28 | ||
| 28 | function makeFakeCodexBinary( | 29 | interface FakeCodexInput { |
| 29 | dir: string, | 30 | output: string; |
| 30 | input: { | 31 | exitCode?: number; |
| 31 | output: string; | 32 | stderr?: string; |
| 32 | exitCode?: number; | 33 | requireImage?: boolean; |
| 33 | stderr?: string; | 34 | requireServiceTier?: string; |
| 34 | requireImage?: boolean; | 35 | requireReasoningEffort?: string; |
| 35 | requireServiceTier?: string; | 36 | forbidReasoningEffort?: boolean; |
| 36 | requireReasoningEffort?: string; | 37 | requireArg?: string; |
| 37 | forbidReasoningEffort?: boolean; | 38 | forbidArg?: string; |
| 38 | requireArg?: string; | 39 | stdinMustContain?: string; |
| 39 | forbidArg?: string; | 40 | stdinMustNotContain?: string; |
| 40 | stdinMustContain?: string; | 41 | } |
| 41 | stdinMustNotContain?: string; | 42 | |
| 42 | }, | 43 | // The stub walks argv the way the shell script it replaced did: `--image`, |
| 43 | ) { | 44 | // `--config key=value`, and `--output-last-message <path>` are consumed, the |
| 45 | // prompt arrives on stdin, and each check exits with its own code so a | ||
| 46 | // failing test names the assertion that tripped. | ||
| 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 | return Effect.gen(function* () { | 61 | return Effect.gen(function* () { |
| 45 | const fs = yield* FileSystem.FileSystem; | ||
| 46 | const path = yield* Path.Path; | 62 | const path = yield* Path.Path; |
| 47 | const binDir = path.join(dir, "bin"); | 63 | return writeFakeCli({ |
| 48 | const codexPath = path.join(binDir, "codex"); | 64 | directory: path.join(dir, "bin"), |
| 49 | yield* fs.makeDirectory(binDir, { recursive: true }); | 65 | name: "codex", |
| 50 | 66 | source: [ | |
| 51 | yield* fs.writeFileString( | 67 | 'import * as NodeFS from "node:fs";', |
| 52 | codexPath, | 68 | `const check = ${check};`, |
| 53 | [ | 69 | "const args = process.argv.slice(2);", |
| 54 | "#!/bin/sh", | 70 | 'const originalArgs = ` ${args.join(" ")} `;', |
| 55 | 'original_args="$*"', | 71 | "let outputPath = null;", |
| 56 | 'output_path=""', | 72 | "let seenImage = false;", |
| 57 | 'seen_image="0"', | 73 | 'let seenServiceTier = "";', |
| 58 | 'seen_service_tier=""', | 74 | 'let seenReasoningEffort = "";', |
| 59 | 'seen_reasoning_effort=""', | 75 | "for (let index = 0; index < args.length; index += 1) {", |
| 60 | "while [ $# -gt 0 ]; do", | 76 | ' if (args[index] === "--image") {', |
| 61 | ' if [ "$1" = "--image" ]; then', | 77 | " index += 1;", |
| 62 | " shift", | 78 | " if (args[index]) seenImage = true;", |
| 63 | ' if [ -n "$1" ]; then', | 79 | ' } else if (args[index] === "--config") {', |
| 64 | ' seen_image="1"', | 80 | " index += 1;", |
| 65 | " fi", | 81 | ' const value = args[index] ?? "";', |
| 66 | " shift", | 82 | ' if (value.startsWith("service_tier=")) seenServiceTier = value;', |
| 67 | " continue", | 83 | ' if (value.startsWith("model_reasoning_effort=")) seenReasoningEffort = value;', |
| 68 | " fi", | 84 | ' } else if (args[index] === "--output-last-message") {', |
| 69 | ' if [ "$1" = "--config" ]; then', | 85 | " index += 1;", |
| 70 | " shift", | 86 | " outputPath = args[index] ?? null;", |
| 71 | ' case "$1" in', | 87 | " }", |
| 72 | " service_tier=*)", | 88 | "}", |
| 73 | ' seen_service_tier="$1"', | 89 | "const chunks = [];", |
| 74 | " ;;", | 90 | "for await (const chunk of process.stdin) chunks.push(chunk);", |
| 75 | " esac", | 91 | 'const stdinContent = Buffer.concat(chunks).toString("utf8");', |
| 76 | ' case "$1" in', | 92 | "function fail(message, code) {", |
| 77 | " model_reasoning_effort=*)", | 93 | ' process.stderr.write(message + "\\n");', |
| 78 | ' seen_reasoning_effort="$1"', | 94 | " process.exit(code);", |
| 79 | " ;;", | 95 | "}", |
| 80 | " esac", | 96 | "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {", |
| 81 | " shift", | 97 | ' fail("missing arg: " + check.requireArg, 8);', |
| 82 | " continue", | 98 | "}", |
| 83 | " fi", | 99 | "if (check.forbidArg !== null && originalArgs.includes(` ${check.forbidArg} `)) {", |
| 84 | ' if [ "$1" = "--output-last-message" ]; then', | 100 | ' fail("forbidden arg: " + check.forbidArg, 9);', |
| 85 | " shift", | 101 | "}", |
| 86 | ' output_path="$1"', | 102 | 'if (check.requireImage && !seenImage) fail("missing --image input", 2);', |
| 87 | " shift", | 103 | "if (", |
| 88 | " continue", | 104 | " check.requireServiceTier !== null &&", |
| 89 | " fi", | 105 | ' seenServiceTier !== `service_tier="${check.requireServiceTier}"`', |
| 90 | " shift", | 106 | ") {", |
| 91 | "done", | 107 | ' fail("unexpected service tier config: " + seenServiceTier, 5);', |
| 92 | 'stdin_content="$(cat)"', | 108 | "}", |
| 93 | ...(input.requireArg !== undefined | 109 | "if (", |
| 94 | ? [ | 110 | " check.requireReasoningEffort !== null &&", |
| 95 | `case " $original_args " in *" ${input.requireArg} "*) ;; *)`, | 111 | ' seenReasoningEffort !== `model_reasoning_effort="${check.requireReasoningEffort}"`', |
| 96 | ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`, | 112 | ") {", |
| 97 | ` exit 8`, | 113 | ' fail("unexpected reasoning effort config: " + seenReasoningEffort, 6);', |
| 98 | "esac", | 114 | "}", |
| 99 | ] | 115 | "if (check.forbidReasoningEffort && seenReasoningEffort.length > 0) {", |
| 100 | : []), | 116 | ' fail("reasoning effort config should be omitted: " + seenReasoningEffort, 7);', |
| 101 | ...(input.forbidArg !== undefined | 117 | "}", |
| 102 | ? [ | 118 | "if (check.stdinMustContain !== null && !stdinContent.includes(check.stdinMustContain)) {", |
| 103 | `case " $original_args " in *" ${input.forbidArg} "*)`, | 119 | ' fail("stdin missing expected content", 3);', |
| 104 | ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`, | 120 | "}", |
| 105 | ` exit 9`, | 121 | "if (check.stdinMustNotContain !== null && stdinContent.includes(check.stdinMustNotContain)) {", |
| 106 | "esac", | 122 | ' fail("stdin contained forbidden content", 4);', |
| 107 | ] | 123 | "}", |
| 108 | : []), | 124 | 'if (check.stderr !== null) process.stderr.write(check.stderr + "\\n");', |
| 109 | ...(input.requireImage | 125 | 'if (outputPath !== null) NodeFS.writeFileSync(outputPath, check.output + "\\n");', |
| 110 | ? [ | 126 | "process.exitCode = check.exitCode;", |
| 111 | 'if [ "$seen_image" != "1" ]; then', | ||
| 112 | ' printf "%s\\n" "missing --image input" >&2', | ||
| 113 | ` exit 2`, | ||
| 114 | "fi", | ||
| 115 | ] | ||
| 116 | : []), | ||
| 117 | ...(input.requireServiceTier | ||
| 118 | ? [ | ||
| 119 | `if [ "$seen_service_tier" != "service_tier=\\"${input.requireServiceTier}\\"" ]; then`, | ||
| 120 | ' printf "%s\\n" "unexpected service tier config: $seen_service_tier" >&2', | ||
| 121 | ` exit 5`, | ||
| 122 | "fi", | ||
| 123 | ] | ||
| 124 | : []), | ||
| 125 | ...(input.requireReasoningEffort !== undefined | ||
| 126 | ? [ | ||
| 127 | `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, | ||
| 128 | ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', | ||
| 129 | ` exit 6`, | ||
| 130 | "fi", | ||
| 131 | ] | ||
| 132 | : []), | ||
| 133 | ...(input.forbidReasoningEffort | ||
| 134 | ? [ | ||
| 135 | 'if [ -n "$seen_reasoning_effort" ]; then', | ||
| 136 | ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', | ||
| 137 | ` exit 7`, | ||
| 138 | "fi", | ||
| 139 | ] | ||
| 140 | : []), | ||
| 141 | ...(input.stdinMustContain !== undefined | ||
| 142 | ? [ | ||
| 143 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 144 | `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, | ||
| 145 | ' printf "%s\\n" "stdin missing expected content" >&2', | ||
| 146 | ` exit 3`, | ||
| 147 | "fi", | ||
| 148 | ] | ||
| 149 | : []), | ||
| 150 | ...(input.stdinMustNotContain !== undefined | ||
| 151 | ? [ | ||
| 152 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 153 | `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, | ||
| 154 | ' printf "%s\\n" "stdin contained forbidden content" >&2', | ||
| 155 | ` exit 4`, | ||
| 156 | "fi", | ||
| 157 | ] | ||
| 158 | : []), | ||
| 159 | ...(input.stderr !== undefined | ||
| 160 | ? [ | ||
| 161 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 162 | `printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`, | ||
| 163 | ] | ||
| 164 | : []), | ||
| 165 | 'if [ -n "$output_path" ]; then', | ||
| 166 | " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", | ||
| 167 | input.output, | ||
| 168 | "__T3CODE_FAKE_CODEX_OUTPUT__", | ||
| 169 | "fi", | ||
| 170 | `exit ${input.exitCode ?? 0}`, | ||
| 171 | "", | 127 | "", |
| 172 | ].join("\n"), | 128 | ].join("\n"), |
| 173 | ); | 129 | }); |
| 174 | yield* fs.chmod(codexPath, 0o755); | ||
| 175 | return codexPath; | ||
| 176 | }); | 130 | }); |
| 177 | } | 131 | } |
| 178 | 132 | ||
| 179 | function withFakeCodexEnv<A, E, R>( | 133 | function withFakeCodexEnv<A, E, R>( |
| 180 | input: { | 134 | input: FakeCodexInput & { |
| 181 | output: string; | ||
| 182 | exitCode?: number; | ||
| 183 | stderr?: string; | ||
| 184 | requireImage?: boolean; | ||
| 185 | requireServiceTier?: string; | ||
| 186 | requireReasoningEffort?: string; | ||
| 187 | forbidReasoningEffort?: boolean; | ||
| 188 | requireArg?: string; | ||
| 189 | forbidArg?: string; | ||
| 190 | stdinMustContain?: string; | ||
| 191 | stdinMustNotContain?: string; | ||
| 192 | launchArgs?: string; | 135 | launchArgs?: string; |
| 193 | environment?: NodeJS.ProcessEnv; | 136 | environment?: NodeJS.ProcessEnv; |
| 194 | }, | 137 | }, |
apps/server/src/textGeneration/CursorTextGeneration.test.ts+49-56| ... | @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 14 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 14 | import { createModelSelection } from "@t3tools/shared/model"; | 15 | import { createModelSelection } from "@t3tools/shared/model"; |
| 15 | import { expect } from "vite-plus/test"; | 16 | import { expect } from "vite-plus/test"; |
| 16 | 17 | ||
| ... | @@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; | ... | @@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 19 | import * as ServerConfig from "../config.ts"; | 20 | import * as ServerConfig from "../config.ts"; |
| 20 | import * as TextGeneration from "./TextGeneration.ts"; | 21 | import * as TextGeneration from "./TextGeneration.ts"; |
| 21 | import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; | 22 | import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; |
| 23 | import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; | ||
| 22 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | 24 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 23 | 25 | ||
| 24 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 26 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 25 | const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); | 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 | const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { | 29 | const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 32 | prefix: "t3code-cursor-text-generation-test-", | 30 | prefix: "t3code-cursor-text-generation-test-", |
| 33 | }).pipe(Layer.provideMerge(NodeServices.layer)); | 31 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 34 | 32 | ||
| 35 | function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { | 33 | function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { |
| 36 | const binDir = NodePath.join(dir, "bin"); | 34 | return writeFakeCli({ |
| 37 | const agentPath = NodePath.join(binDir, "agent"); | 35 | directory: NodePath.join(dir, "bin"), |
| 38 | NodeFS.mkdirSync(binDir, { recursive: true }); | 36 | name: "agent", |
| 39 | NodeFS.writeFileSync( | 37 | env, |
| 40 | agentPath, | 38 | source: execScriptSource({ |
| 41 | [ | 39 | scriptPath: mockAgentPath, |
| 42 | "#!/bin/sh", | 40 | expectedArgs: ["acp"], |
| 43 | ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), | 41 | }), |
| 44 | 'if [ "$1" != "acp" ]; then', | 42 | }); |
| 45 | ' printf "%s\\n" "unexpected args: $*" >&2', | ||
| 46 | " exit 11", | ||
| 47 | "fi", | ||
| 48 | `exec node ${JSON.stringify(mockAgentPath)}`, | ||
| 49 | "", | ||
| 50 | ].join("\n"), | ||
| 51 | "utf8", | ||
| 52 | ); | ||
| 53 | NodeFS.chmodSync(agentPath, 0o755); | ||
| 54 | return agentPath; | ||
| 55 | } | 43 | } |
| 56 | 44 | ||
| 57 | function withFakeAcpAgent<A, E, R>( | 45 | function withFakeAcpAgent<A, E, R>( |
| ... | @@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { | ... | @@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { |
| 236 | ), | 224 | ), |
| 237 | ); | 225 | ); |
| 238 | 226 | ||
| 239 | it.effect("closes the ACP child process after text generation completes", () => { | 227 | // Closing the runtime on Windows is taskkill /F, which never lets the mock |
| 240 | const exitLogDir = NodeFS.mkdtempSync( | 228 | // agent reach its exit handler, so there is no exit log to assert on. |
| 241 | NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"), | 229 | it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( |
| 242 | ); | 230 | "closes the ACP child process after text generation completes", |
| 243 | const exitLogPath = NodePath.join(exitLogDir, "exit.log"); | 231 | () => { |
| 232 | const exitLogDir = NodeFS.mkdtempSync( | ||
| 233 | NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"), | ||
| 234 | ); | ||
| 235 | const exitLogPath = NodePath.join(exitLogDir, "exit.log"); | ||
| 244 | 236 | ||
| 245 | return withFakeAcpAgent( | 237 | return withFakeAcpAgent( |
| 246 | { | 238 | { |
| 247 | T3_ACP_EXIT_LOG_PATH: exitLogPath, | 239 | T3_ACP_EXIT_LOG_PATH: exitLogPath, |
| 248 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | 240 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ |
| 249 | subject: "Close runtime after generation", | 241 | subject: "Close runtime after generation", |
| 250 | body: "", | 242 | body: "", |
| 251 | }), | 243 | }), |
| 252 | }, | 244 | }, |
| 253 | (textGeneration) => | 245 | (textGeneration) => |
| 254 | Effect.gen(function* () { | 246 | Effect.gen(function* () { |
| 255 | const generated = yield* textGeneration.generateCommitMessage({ | 247 | const generated = yield* textGeneration.generateCommitMessage({ |
| 256 | cwd: process.cwd(), | 248 | cwd: process.cwd(), |
| 257 | branch: "feature/cursor-runtime-close", | 249 | branch: "feature/cursor-runtime-close", |
| 258 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", | 250 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", |
| 259 | stagedPatch: | 251 | stagedPatch: |
| 260 | "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", | 252 | "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", |
| 261 | modelSelection: { | 253 | modelSelection: { |
| 262 | instanceId: ProviderInstanceId.make("cursor"), | 254 | instanceId: ProviderInstanceId.make("cursor"), |
| 263 | model: "composer-2", | 255 | model: "composer-2", |
| 264 | }, | 256 | }, |
| 265 | }); | 257 | }); |
| 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); | 261 | const exitLog = yield* waitForFileContent(exitLogPath); |
| 270 | expect(exitLog).toContain("exit:0"); | 262 | expect(exitLog).toContain("exit:0"); |
| 271 | 263 | ||
| 272 | NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); | 264 | NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); |
| 273 | }), | 265 | }), |
| 274 | ); | 266 | ); |
| 275 | }); | 267 | }, |
| 268 | ); | ||
| 276 | }); | 269 | }); |
apps/server/src/textGeneration/GrokTextGeneration.test.ts+10-23| ... | @@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; | ... | @@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 16 | import * as ServerConfig from "../config.ts"; | 16 | import * as ServerConfig from "../config.ts"; |
| 17 | import * as TextGeneration from "./TextGeneration.ts"; | 17 | import * as TextGeneration from "./TextGeneration.ts"; |
| 18 | import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; | 18 | import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; |
| 19 | import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; | ||
| 19 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 20 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 20 | 21 | ||
| 21 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 22 | const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); | 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 | const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { | 25 | const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 29 | prefix: "t3code-grok-text-generation-test-", | 26 | prefix: "t3code-grok-text-generation-test-", |
| 30 | }).pipe(Layer.provideMerge(NodeServices.layer)); | 27 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 31 | 28 | ||
| 32 | function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string { | 29 | function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string { |
| 33 | const binDir = NodePath.join(dir, "bin"); | 30 | return writeFakeCli({ |
| 34 | const grokPath = NodePath.join(binDir, "grok"); | 31 | directory: NodePath.join(dir, "bin"), |
| 35 | NodeFS.mkdirSync(binDir, { recursive: true }); | 32 | name: "grok", |
| 36 | NodeFS.writeFileSync( | 33 | env, |
| 37 | grokPath, | 34 | source: execScriptSource({ |
| 38 | [ | 35 | scriptPath: mockAgentPath, |
| 39 | "#!/bin/sh", | 36 | expectedArgs: ["agent", "stdio"], |
| 40 | ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), | 37 | }), |
| 41 | 'if [ "$1" != "agent" ] || [ "$2" != "stdio" ]; then', | 38 | }); |
| 42 | ' printf "%s\\n" "unexpected args: $*" >&2', | ||
| 43 | " exit 11", | ||
| 44 | "fi", | ||
| 45 | `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`, | ||
| 46 | "", | ||
| 47 | ].join("\n"), | ||
| 48 | "utf8", | ||
| 49 | ); | ||
| 50 | NodeFS.chmodSync(grokPath, 0o755); | ||
| 51 | return grokPath; | ||
| 52 | } | 39 | } |
| 53 | 40 | ||
| 54 | function withFakeAcpGrok<A, E, R>( | 41 | function withFakeAcpGrok<A, E, R>( |