| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Result from "effect/Result"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as Scope from "effect/Scope"; |
| 11 | import { createModelSelection } from "@t3tools/shared/model"; |
| 12 | import { expect } from "vite-plus/test"; |
| 13 | |
| 14 | import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; |
| 15 | |
| 16 | import * as ServerConfig from "../config.ts"; |
| 17 | import * as TextGeneration from "./TextGeneration.ts"; |
| 18 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; |
| 19 | import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; |
| 20 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| 21 | |
| 22 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( |
| 23 | ProviderInstanceId.make("codex"), |
| 24 | "gpt-5.4-mini", |
| 25 | ); |
| 26 | |
| 27 | const layerCodexTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 28 | prefix: "t3code-codex-text-generation-test-", |
| 29 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 30 | |
| 31 | interface FakeCodexInput { |
| 32 | output: string; |
| 33 | exitCode?: number; |
| 34 | stderr?: string; |
| 35 | requireImage?: boolean; |
| 36 | requireServiceTier?: string; |
| 37 | requireReasoningEffort?: string; |
| 38 | forbidReasoningEffort?: boolean; |
| 39 | requireArg?: string; |
| 40 | forbidArg?: string; |
| 41 | stdinMustContain?: string; |
| 42 | stdinMustNotContain?: string; |
| 43 | /** What `codex mcp list --json` answers; a number exits with that code instead. */ |
| 44 | mcpServers?: ReadonlyArray<{ name: string; enabled: boolean }> | number; |
| 45 | } |
| 46 | |
| 47 | // The stub walks argv the way the shell script it replaced did: `--image`, |
| 48 | // `--config key=value`, and `--output-last-message <path>` are consumed, the |
| 49 | // prompt arrives on stdin, and each check exits with its own code so a |
| 50 | // failing test names the assertion that tripped. |
| 51 | function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { |
| 52 | const check = JSON.stringify({ |
| 53 | requireImage: input.requireImage ?? false, |
| 54 | requireServiceTier: input.requireServiceTier ?? null, |
| 55 | requireReasoningEffort: input.requireReasoningEffort ?? null, |
| 56 | forbidReasoningEffort: input.forbidReasoningEffort ?? false, |
| 57 | requireArg: input.requireArg ?? null, |
| 58 | forbidArg: input.forbidArg ?? null, |
| 59 | stdinMustContain: input.stdinMustContain ?? null, |
| 60 | stdinMustNotContain: input.stdinMustNotContain ?? null, |
| 61 | mcpServers: input.mcpServers ?? [], |
| 62 | stderr: input.stderr ?? null, |
| 63 | output: input.output, |
| 64 | exitCode: input.exitCode ?? 0, |
| 65 | }); |
| 66 | return Effect.gen(function* () { |
| 67 | const path = yield* Path.Path; |
| 68 | return writeFakeCli({ |
| 69 | directory: path.join(dir, "bin"), |
| 70 | name: "codex", |
| 71 | source: [ |
| 72 | 'import * as NodeFS from "node:fs";', |
| 73 | `const check = ${check};`, |
| 74 | "const args = process.argv.slice(2);", |
| 75 | 'if (args.includes("mcp") && args.includes("list")) {', |
| 76 | ' if (typeof check.mcpServers === "number") process.exit(check.mcpServers);', |
| 77 | " process.stdout.write(JSON.stringify(check.mcpServers));", |
| 78 | " process.exit(0);", |
| 79 | "}", |
| 80 | 'const originalArgs = ` ${args.join(" ")} `;', |
| 81 | "let outputPath = null;", |
| 82 | "let seenImage = false;", |
| 83 | 'let seenServiceTier = "";', |
| 84 | 'let seenReasoningEffort = "";', |
| 85 | "for (let index = 0; index < args.length; index += 1) {", |
| 86 | ' if (args[index] === "--image") {', |
| 87 | " index += 1;", |
| 88 | " if (args[index]) seenImage = true;", |
| 89 | ' } else if (args[index] === "--config") {', |
| 90 | " index += 1;", |
| 91 | ' const value = args[index] ?? "";', |
| 92 | ' if (value.startsWith("service_tier=")) seenServiceTier = value;', |
| 93 | ' if (value.startsWith("model_reasoning_effort=")) seenReasoningEffort = value;', |
| 94 | ' } else if (args[index] === "--output-last-message") {', |
| 95 | " index += 1;", |
| 96 | " outputPath = args[index] ?? null;", |
| 97 | " }", |
| 98 | "}", |
| 99 | "const chunks = [];", |
| 100 | "for await (const chunk of process.stdin) chunks.push(chunk);", |
| 101 | 'const stdinContent = Buffer.concat(chunks).toString("utf8");', |
| 102 | "function fail(message, code) {", |
| 103 | ' process.stderr.write(message + "\\n");', |
| 104 | " process.exit(code);", |
| 105 | "}", |
| 106 | "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {", |
| 107 | ' fail("missing arg: " + check.requireArg, 8);', |
| 108 | "}", |
| 109 | "if (check.forbidArg !== null && originalArgs.includes(` ${check.forbidArg} `)) {", |
| 110 | ' fail("forbidden arg: " + check.forbidArg, 9);', |
| 111 | "}", |
| 112 | 'if (check.requireImage && !seenImage) fail("missing --image input", 2);', |
| 113 | "if (", |
| 114 | " check.requireServiceTier !== null &&", |
| 115 | ' seenServiceTier !== `service_tier="${check.requireServiceTier}"`', |
| 116 | ") {", |
| 117 | ' fail("unexpected service tier config: " + seenServiceTier, 5);', |
| 118 | "}", |
| 119 | "if (", |
| 120 | " check.requireReasoningEffort !== null &&", |
| 121 | ' seenReasoningEffort !== `model_reasoning_effort="${check.requireReasoningEffort}"`', |
| 122 | ") {", |
| 123 | ' fail("unexpected reasoning effort config: " + seenReasoningEffort, 6);', |
| 124 | "}", |
| 125 | "if (check.forbidReasoningEffort && seenReasoningEffort.length > 0) {", |
| 126 | ' fail("reasoning effort config should be omitted: " + seenReasoningEffort, 7);', |
| 127 | "}", |
| 128 | "if (check.stdinMustContain !== null && !stdinContent.includes(check.stdinMustContain)) {", |
| 129 | ' fail("stdin missing expected content", 3);', |
| 130 | "}", |
| 131 | "if (check.stdinMustNotContain !== null && stdinContent.includes(check.stdinMustNotContain)) {", |
| 132 | ' fail("stdin contained forbidden content", 4);', |
| 133 | "}", |
| 134 | 'if (check.stderr !== null) process.stderr.write(check.stderr + "\\n");', |
| 135 | 'if (outputPath !== null) NodeFS.writeFileSync(outputPath, check.output + "\\n");', |
| 136 | "process.exitCode = check.exitCode;", |
| 137 | "", |
| 138 | ].join("\n"), |
| 139 | }); |
| 140 | }); |
| 141 | } |
| 142 | |
| 143 | function withFakeCodexEnv<A, E, R>( |
| 144 | input: FakeCodexInput & { |
| 145 | launchArgs?: string; |
| 146 | environment?: NodeJS.ProcessEnv; |
| 147 | models?: ReadonlyArray<string>; |
| 148 | managedRuntime?: boolean; |
| 149 | }, |
| 150 | effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>, |
| 151 | ) { |
| 152 | return Effect.gen(function* () { |
| 153 | const fs = yield* FileSystem.FileSystem; |
| 154 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); |
| 155 | const codexPath = yield* makeFakeCodexBinary(tempDir, input); |
| 156 | const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs }); |
| 157 | const textGeneration = yield* makeCodexTextGeneration( |
| 158 | config, |
| 159 | input.environment === undefined ? undefined : { ...process.env, ...input.environment }, |
| 160 | Effect.succeed( |
| 161 | (input.models ?? []).map((slug) => ({ |
| 162 | slug, |
| 163 | name: slug, |
| 164 | isCustom: false, |
| 165 | capabilities: null, |
| 166 | })), |
| 167 | ), |
| 168 | input.managedRuntime |
| 169 | ? Effect.succeed({ |
| 170 | config, |
| 171 | environment: input.environment ?? process.env, |
| 172 | revision: "test", |
| 173 | }) |
| 174 | : undefined, |
| 175 | ); |
| 176 | return yield* effectFn(textGeneration); |
| 177 | }).pipe(Effect.scoped); |
| 178 | } |
| 179 | |
| 180 | it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { |
| 181 | it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])( |
| 182 | "dispatches the qualified live model for %s", |
| 183 | (selectedModel) => |
| 184 | withFakeCodexEnv( |
| 185 | { |
| 186 | output: JSON.stringify({ title: "Bedrock title" }), |
| 187 | models: ["openai.gpt-5.6-luna"], |
| 188 | requireArg: "--model openai.gpt-5.6-luna", |
| 189 | forbidArg: "--model gpt-5.6-luna", |
| 190 | }, |
| 191 | (textGeneration) => |
| 192 | Effect.gen(function* () { |
| 193 | const result = yield* textGeneration.generateThreadTitle({ |
| 194 | cwd: process.cwd(), |
| 195 | message: "Describe this change", |
| 196 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), selectedModel), |
| 197 | }); |
| 198 | expect(result.title).toBe("Bedrock title"); |
| 199 | }), |
| 200 | ), |
| 201 | ); |
| 202 | it.effect("generates and sanitizes commit messages without branch by default", () => |
| 203 | withFakeCodexEnv( |
| 204 | { |
| 205 | output: JSON.stringify({ |
| 206 | subject: |
| 207 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", |
| 208 | body: "\n- added migration\n- updated tests\n", |
| 209 | }), |
| 210 | stdinMustNotContain: "branch must be a short semantic git branch fragment", |
| 211 | }, |
| 212 | (textGeneration) => |
| 213 | Effect.gen(function* () { |
| 214 | const generated = yield* textGeneration.generateCommitMessage({ |
| 215 | cwd: process.cwd(), |
| 216 | branch: "feature/codex-effect", |
| 217 | stagedSummary: "M README.md", |
| 218 | stagedPatch: "diff --git a/README.md b/README.md", |
| 219 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 220 | }); |
| 221 | |
| 222 | expect(generated.subject.length).toBeLessThanOrEqual(72); |
| 223 | expect(generated.subject.endsWith(".")).toBe(false); |
| 224 | expect(generated.body).toBe("- added migration\n- updated tests"); |
| 225 | expect(generated.branch).toBeUndefined(); |
| 226 | }), |
| 227 | ), |
| 228 | ); |
| 229 | |
| 230 | it.effect( |
| 231 | "forwards codex service tier and non-default reasoning effort into codex exec config", |
| 232 | () => |
| 233 | withFakeCodexEnv( |
| 234 | { |
| 235 | output: JSON.stringify({ |
| 236 | subject: "Add important change", |
| 237 | body: "", |
| 238 | }), |
| 239 | requireServiceTier: "priority", |
| 240 | requireReasoningEffort: "xhigh", |
| 241 | stdinMustNotContain: "branch must be a short semantic git branch fragment", |
| 242 | }, |
| 243 | (textGeneration) => |
| 244 | textGeneration.generateCommitMessage({ |
| 245 | cwd: process.cwd(), |
| 246 | branch: "feature/codex-effect", |
| 247 | stagedSummary: "M README.md", |
| 248 | stagedPatch: "diff --git a/README.md b/README.md", |
| 249 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 250 | { id: "reasoningEffort", value: "xhigh" }, |
| 251 | { id: "serviceTier", value: "priority" }, |
| 252 | ]), |
| 253 | }), |
| 254 | ), |
| 255 | ); |
| 256 | |
| 257 | it.effect("omits a persisted service tier for managed ChatGPT text generation", () => |
| 258 | withFakeCodexEnv( |
| 259 | { |
| 260 | output: JSON.stringify({ subject: "Update project", body: "" }), |
| 261 | managedRuntime: true, |
| 262 | forbidArg: 'service_tier="priority"', |
| 263 | }, |
| 264 | (textGeneration) => |
| 265 | textGeneration.generateCommitMessage({ |
| 266 | cwd: process.cwd(), |
| 267 | branch: "feature/chatgpt", |
| 268 | stagedSummary: "M README.md", |
| 269 | stagedPatch: "diff --git a/README.md b/README.md", |
| 270 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 271 | { id: "serviceTier", value: "priority" }, |
| 272 | ]), |
| 273 | }), |
| 274 | ), |
| 275 | ); |
| 276 | |
| 277 | it.effect("generates titles with the user's MCP servers and plugins off", () => |
| 278 | withFakeCodexEnv( |
| 279 | { |
| 280 | output: JSON.stringify({ title: "Remove default shapes" }), |
| 281 | mcpServers: [ |
| 282 | { name: "augmenter", enabled: true }, |
| 283 | { name: "my.server", enabled: true }, |
| 284 | { name: "already-off", enabled: false }, |
| 285 | ], |
| 286 | requireArg: |
| 287 | '--config features.plugins=false --config features.apps=false --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}}', |
| 288 | }, |
| 289 | (textGeneration) => |
| 290 | Effect.gen(function* () { |
| 291 | const result = yield* textGeneration.generateThreadTitle({ |
| 292 | cwd: process.cwd(), |
| 293 | message: "Augmenter task 1. Use the Augmenter MCP: link_thread when you start.", |
| 294 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 295 | }); |
| 296 | expect(result.title).toBe("Remove default shapes"); |
| 297 | }), |
| 298 | ), |
| 299 | ); |
| 300 | |
| 301 | it.effect("still generates when Codex cannot list its MCP servers", () => |
| 302 | withFakeCodexEnv( |
| 303 | { |
| 304 | output: JSON.stringify({ branch: "remove-default-shapes" }), |
| 305 | mcpServers: 2, |
| 306 | requireArg: "--config features.plugins=false --config features.apps=false --ephemeral", |
| 307 | forbidArg: "mcp_servers=", |
| 308 | }, |
| 309 | (textGeneration) => |
| 310 | Effect.gen(function* () { |
| 311 | const result = yield* textGeneration.generateBranchName({ |
| 312 | cwd: process.cwd(), |
| 313 | message: "Remove default shapes from characters", |
| 314 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 315 | }); |
| 316 | expect(result.branch).toBe("remove-default-shapes"); |
| 317 | }), |
| 318 | ), |
| 319 | ); |
| 320 | |
| 321 | it.effect("keeps plugins and apps off over launch args that turn them on", () => |
| 322 | withFakeCodexEnv( |
| 323 | { |
| 324 | output: JSON.stringify({ title: "Remove default shapes" }), |
| 325 | launchArgs: "--config features.plugins=true --config features.apps=true", |
| 326 | requireArg: |
| 327 | "--config features.plugins=true --config features.apps=true --config features.plugins=false --config features.apps=false --ephemeral", |
| 328 | }, |
| 329 | (textGeneration) => |
| 330 | Effect.gen(function* () { |
| 331 | const result = yield* textGeneration.generateThreadTitle({ |
| 332 | cwd: process.cwd(), |
| 333 | message: "Remove default shapes from characters", |
| 334 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 335 | }); |
| 336 | expect(result.title).toBe("Remove default shapes"); |
| 337 | }), |
| 338 | ), |
| 339 | ); |
| 340 | |
| 341 | it.effect("passes exec-safe launch args into codex exec", () => |
| 342 | withFakeCodexEnv( |
| 343 | { |
| 344 | output: JSON.stringify({ |
| 345 | subject: "Add important change", |
| 346 | body: "", |
| 347 | }), |
| 348 | launchArgs: "--strict-config --listen off", |
| 349 | requireArg: "--strict-config", |
| 350 | forbidArg: "--listen", |
| 351 | }, |
| 352 | (textGeneration) => |
| 353 | textGeneration.generateCommitMessage({ |
| 354 | cwd: process.cwd(), |
| 355 | branch: "feature/codex-effect", |
| 356 | stagedSummary: "M README.md", |
| 357 | stagedPatch: "diff --git a/README.md b/README.md", |
| 358 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 359 | }), |
| 360 | ), |
| 361 | ); |
| 362 | |
| 363 | it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for codex exec over settings", () => |
| 364 | withFakeCodexEnv( |
| 365 | { |
| 366 | output: JSON.stringify({ |
| 367 | subject: "Add important change", |
| 368 | body: "", |
| 369 | }), |
| 370 | launchArgs: "--enable settings-feature", |
| 371 | environment: { ...process.env, T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --listen off " }, |
| 372 | requireArg: "--strict-config", |
| 373 | forbidArg: "settings-feature", |
| 374 | }, |
| 375 | (textGeneration) => |
| 376 | textGeneration.generateCommitMessage({ |
| 377 | cwd: process.cwd(), |
| 378 | branch: "feature/codex-effect", |
| 379 | stagedSummary: "M README.md", |
| 380 | stagedPatch: "diff --git a/README.md b/README.md", |
| 381 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 382 | }), |
| 383 | ), |
| 384 | ); |
| 385 | |
| 386 | it.effect("defaults git text generation codex effort to low", () => |
| 387 | withFakeCodexEnv( |
| 388 | { |
| 389 | output: JSON.stringify({ |
| 390 | subject: "Add important change", |
| 391 | body: "", |
| 392 | }), |
| 393 | requireReasoningEffort: "low", |
| 394 | }, |
| 395 | (textGeneration) => |
| 396 | textGeneration.generateCommitMessage({ |
| 397 | cwd: process.cwd(), |
| 398 | branch: "feature/codex-effect", |
| 399 | stagedSummary: "M README.md", |
| 400 | stagedPatch: "diff --git a/README.md b/README.md", |
| 401 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 402 | }), |
| 403 | ), |
| 404 | ); |
| 405 | |
| 406 | it.effect("generates commit message with branch when includeBranch is true", () => |
| 407 | withFakeCodexEnv( |
| 408 | { |
| 409 | output: JSON.stringify({ |
| 410 | subject: "Add important change", |
| 411 | body: "", |
| 412 | branch: "fix/important-system-change", |
| 413 | }), |
| 414 | stdinMustContain: "branch must be a short semantic git branch fragment", |
| 415 | }, |
| 416 | (textGeneration) => |
| 417 | Effect.gen(function* () { |
| 418 | const generated = yield* textGeneration.generateCommitMessage({ |
| 419 | cwd: process.cwd(), |
| 420 | branch: "feature/codex-effect", |
| 421 | stagedSummary: "M README.md", |
| 422 | stagedPatch: "diff --git a/README.md b/README.md", |
| 423 | includeBranch: true, |
| 424 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 425 | }); |
| 426 | |
| 427 | expect(generated.subject).toBe("Add important change"); |
| 428 | expect(generated.branch).toBe("fix/important-system-change"); |
| 429 | }), |
| 430 | ), |
| 431 | ); |
| 432 | |
| 433 | it.effect("generates PR content and trims markdown body", () => |
| 434 | withFakeCodexEnv( |
| 435 | { |
| 436 | output: JSON.stringify({ |
| 437 | title: " Improve orchestration flow\nwith ignored suffix", |
| 438 | body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", |
| 439 | }), |
| 440 | }, |
| 441 | (textGeneration) => |
| 442 | Effect.gen(function* () { |
| 443 | const generated = yield* textGeneration.generatePrContent({ |
| 444 | cwd: process.cwd(), |
| 445 | baseBranch: "main", |
| 446 | headBranch: "feature/codex-effect", |
| 447 | commitSummary: "feat: improve orchestration flow", |
| 448 | diffSummary: "2 files changed", |
| 449 | diffPatch: "diff --git a/a.ts b/a.ts", |
| 450 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 451 | }); |
| 452 | |
| 453 | expect(generated.title).toBe("Improve orchestration flow"); |
| 454 | expect(generated.body.startsWith("## Summary")).toBe(true); |
| 455 | expect(generated.body.endsWith("\n\n")).toBe(false); |
| 456 | }), |
| 457 | ), |
| 458 | ); |
| 459 | |
| 460 | it.effect("generates branch names and normalizes branch fragments", () => |
| 461 | withFakeCodexEnv( |
| 462 | { |
| 463 | output: JSON.stringify({ |
| 464 | branch: " Feat/Session ", |
| 465 | }), |
| 466 | stdinMustNotContain: "Image attachments supplied to the model", |
| 467 | }, |
| 468 | (textGeneration) => |
| 469 | Effect.gen(function* () { |
| 470 | const generated = yield* textGeneration.generateBranchName({ |
| 471 | cwd: process.cwd(), |
| 472 | message: "Please update session handling.", |
| 473 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 474 | }); |
| 475 | |
| 476 | expect(generated.branch).toBe("feat/session"); |
| 477 | }), |
| 478 | ), |
| 479 | ); |
| 480 | |
| 481 | it.effect.each([ |
| 482 | { |
| 483 | mode: "static", |
| 484 | output: "Add Search", |
| 485 | expected: "team/add-search", |
| 486 | instruction: "without a prefix or namespace", |
| 487 | }, |
| 488 | { |
| 489 | mode: "semantic", |
| 490 | output: "feat/add-search", |
| 491 | expected: "feat/add-search", |
| 492 | instruction: "semantic prefix", |
| 493 | }, |
| 494 | { |
| 495 | mode: "custom", |
| 496 | output: "Julius/ABC-123.v2", |
| 497 | expected: "Julius/ABC-123.v2", |
| 498 | instruction: "Preserve the issue ID and capitalization.", |
| 499 | }, |
| 500 | ] as const)("generates a branch using $mode naming", (example) => |
| 501 | withFakeCodexEnv( |
| 502 | { |
| 503 | output: JSON.stringify({ branch: example.output }), |
| 504 | stdinMustContain: example.instruction, |
| 505 | }, |
| 506 | (textGeneration) => |
| 507 | Effect.gen(function* () { |
| 508 | const generated = yield* textGeneration.generateBranchName({ |
| 509 | cwd: process.cwd(), |
| 510 | message: "Add search", |
| 511 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 512 | naming: { |
| 513 | mode: example.mode, |
| 514 | prefix: "team/", |
| 515 | instructions: "Preserve the issue ID and capitalization.", |
| 516 | }, |
| 517 | }); |
| 518 | expect(generated.branch).toBe(example.expected); |
| 519 | }), |
| 520 | ), |
| 521 | ); |
| 522 | |
| 523 | it.effect("generates branch names even when the ambient scope is already closed", () => |
| 524 | withFakeCodexEnv( |
| 525 | { |
| 526 | output: JSON.stringify({ |
| 527 | branch: "feat/background-generation", |
| 528 | }), |
| 529 | }, |
| 530 | (textGeneration) => |
| 531 | Effect.gen(function* () { |
| 532 | // Background fibers (e.g. the worktree branch rename fork) can run |
| 533 | // after their launching request's scope has closed; temp files must |
| 534 | // not be tied to that ambient scope or they are reaped on creation. |
| 535 | const closedScope = yield* Scope.make(); |
| 536 | yield* Scope.close(closedScope, Exit.void); |
| 537 | |
| 538 | const generated = yield* textGeneration |
| 539 | .generateBranchName({ |
| 540 | cwd: process.cwd(), |
| 541 | message: "Please update session handling.", |
| 542 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 543 | }) |
| 544 | .pipe(Effect.provideService(Scope.Scope, closedScope)); |
| 545 | |
| 546 | expect(generated.branch).toBe("feat/background-generation"); |
| 547 | }), |
| 548 | ), |
| 549 | ); |
| 550 | |
| 551 | it.effect("generates thread titles and trims them for sidebar use", () => |
| 552 | withFakeCodexEnv( |
| 553 | { |
| 554 | output: JSON.stringify({ |
| 555 | title: |
| 556 | ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', |
| 557 | }), |
| 558 | }, |
| 559 | (textGeneration) => |
| 560 | Effect.gen(function* () { |
| 561 | const generated = yield* textGeneration.generateThreadTitle({ |
| 562 | cwd: process.cwd(), |
| 563 | message: "Please investigate websocket reconnect regressions after a worktree restore.", |
| 564 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 565 | }); |
| 566 | |
| 567 | expect(generated.title).toBe( |
| 568 | "Investigate websocket reconnect regressions after worktree restore", |
| 569 | ); |
| 570 | }), |
| 571 | ), |
| 572 | ); |
| 573 | |
| 574 | it.effect("returns the refinement signal for an unresolved subject", () => |
| 575 | withFakeCodexEnv( |
| 576 | { output: JSON.stringify({ title: "Investigate issue", needsRefinement: true }) }, |
| 577 | (textGeneration) => |
| 578 | Effect.gen(function* () { |
| 579 | expect( |
| 580 | yield* textGeneration.generateThreadTitle({ |
| 581 | cwd: process.cwd(), |
| 582 | message: "Fix this", |
| 583 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 584 | }), |
| 585 | ).toEqual({ title: "Investigate issue", needsRefinement: true }); |
| 586 | }), |
| 587 | ), |
| 588 | ); |
| 589 | |
| 590 | it.effect("falls back when thread title normalization becomes whitespace-only", () => |
| 591 | withFakeCodexEnv( |
| 592 | { |
| 593 | output: JSON.stringify({ |
| 594 | title: ' """ """ ', |
| 595 | }), |
| 596 | }, |
| 597 | (textGeneration) => |
| 598 | Effect.gen(function* () { |
| 599 | const generated = yield* textGeneration.generateThreadTitle({ |
| 600 | cwd: process.cwd(), |
| 601 | message: "Name this thread.", |
| 602 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 603 | }); |
| 604 | |
| 605 | expect(generated.title).toBe("New thread"); |
| 606 | }), |
| 607 | ), |
| 608 | ); |
| 609 | |
| 610 | it.effect("trims whitespace exposed after quote removal in thread titles", () => |
| 611 | withFakeCodexEnv( |
| 612 | { |
| 613 | output: JSON.stringify({ |
| 614 | title: ` "' hello world '" `, |
| 615 | }), |
| 616 | }, |
| 617 | (textGeneration) => |
| 618 | Effect.gen(function* () { |
| 619 | const generated = yield* textGeneration.generateThreadTitle({ |
| 620 | cwd: process.cwd(), |
| 621 | message: "Name this thread.", |
| 622 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 623 | }); |
| 624 | |
| 625 | expect(generated.title).toBe("hello world"); |
| 626 | }), |
| 627 | ), |
| 628 | ); |
| 629 | |
| 630 | it.effect("omits attachment metadata section when no attachments are provided", () => |
| 631 | withFakeCodexEnv( |
| 632 | { |
| 633 | output: JSON.stringify({ |
| 634 | branch: "fix/session-timeout", |
| 635 | }), |
| 636 | stdinMustNotContain: "Attachment metadata:", |
| 637 | }, |
| 638 | (textGeneration) => |
| 639 | Effect.gen(function* () { |
| 640 | const generated = yield* textGeneration.generateBranchName({ |
| 641 | cwd: process.cwd(), |
| 642 | message: "Fix timeout behavior.", |
| 643 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 644 | }); |
| 645 | |
| 646 | expect(generated.branch).toBe("fix/session-timeout"); |
| 647 | }), |
| 648 | ), |
| 649 | ); |
| 650 | |
| 651 | it.effect("passes image attachments through as codex image inputs", () => |
| 652 | withFakeCodexEnv( |
| 653 | { |
| 654 | output: JSON.stringify({ |
| 655 | branch: "fix/ui-regression", |
| 656 | }), |
| 657 | requireImage: true, |
| 658 | stdinMustContain: "Attachment metadata:", |
| 659 | }, |
| 660 | (textGeneration) => |
| 661 | Effect.gen(function* () { |
| 662 | const fs = yield* FileSystem.FileSystem; |
| 663 | const path = yield* Path.Path; |
| 664 | const { attachmentsDir } = yield* ServerConfig.ServerConfig; |
| 665 | const attachmentId = "thread-branch-image-attachment"; |
| 666 | const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); |
| 667 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); |
| 668 | yield* fs.writeFile(attachmentPath, Buffer.from("hello")); |
| 669 | |
| 670 | const generated = yield* textGeneration.generateBranchName({ |
| 671 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 672 | cwd: process.cwd(), |
| 673 | message: "Fix layout bug from screenshot.", |
| 674 | attachments: [ |
| 675 | { |
| 676 | type: "image", |
| 677 | id: attachmentId, |
| 678 | name: "bug.png", |
| 679 | mimeType: "image/png", |
| 680 | sizeBytes: 5, |
| 681 | }, |
| 682 | ], |
| 683 | }); |
| 684 | |
| 685 | expect(generated.branch).toBe("fix/ui-regression"); |
| 686 | }), |
| 687 | ), |
| 688 | ); |
| 689 | |
| 690 | it.effect("resolves persisted attachment ids to files for codex image inputs", () => |
| 691 | withFakeCodexEnv( |
| 692 | { |
| 693 | output: JSON.stringify({ |
| 694 | branch: "fix/ui-regression", |
| 695 | }), |
| 696 | requireImage: true, |
| 697 | }, |
| 698 | (textGeneration) => |
| 699 | Effect.gen(function* () { |
| 700 | const fs = yield* FileSystem.FileSystem; |
| 701 | const path = yield* Path.Path; |
| 702 | const { attachmentsDir } = yield* ServerConfig.ServerConfig; |
| 703 | const attachmentId = "thread-1-attachment"; |
| 704 | const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); |
| 705 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); |
| 706 | yield* fs.writeFile(imagePath, Buffer.from("hello")); |
| 707 | |
| 708 | const generated = yield* textGeneration |
| 709 | .generateBranchName({ |
| 710 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 711 | cwd: process.cwd(), |
| 712 | message: "Fix layout bug from screenshot.", |
| 713 | attachments: [ |
| 714 | { |
| 715 | type: "image", |
| 716 | id: attachmentId, |
| 717 | name: "bug.png", |
| 718 | mimeType: "image/png", |
| 719 | sizeBytes: 5, |
| 720 | }, |
| 721 | ], |
| 722 | }) |
| 723 | .pipe( |
| 724 | Effect.tap(() => |
| 725 | fs.stat(imagePath).pipe( |
| 726 | Effect.map((fileInfo) => { |
| 727 | expect(fileInfo.type).toBe("File"); |
| 728 | }), |
| 729 | ), |
| 730 | ), |
| 731 | Effect.ensuring(fs.remove(imagePath).pipe(Effect.ignore)), |
| 732 | ); |
| 733 | |
| 734 | expect(generated.branch).toBe("fix/ui-regression"); |
| 735 | }), |
| 736 | ), |
| 737 | ); |
| 738 | |
| 739 | it.effect("ignores missing attachment ids for codex image inputs", () => |
| 740 | withFakeCodexEnv( |
| 741 | { |
| 742 | output: JSON.stringify({ |
| 743 | branch: "fix/ui-regression", |
| 744 | }), |
| 745 | requireImage: true, |
| 746 | }, |
| 747 | (textGeneration) => |
| 748 | Effect.gen(function* () { |
| 749 | const fs = yield* FileSystem.FileSystem; |
| 750 | const path = yield* Path.Path; |
| 751 | const { attachmentsDir } = yield* ServerConfig.ServerConfig; |
| 752 | const missingAttachmentId = "thread-missing-attachment"; |
| 753 | const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); |
| 754 | yield* fs.remove(missingPath).pipe(Effect.ignore); |
| 755 | |
| 756 | const result = yield* textGeneration |
| 757 | .generateBranchName({ |
| 758 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 759 | cwd: process.cwd(), |
| 760 | message: "Fix layout bug from screenshot.", |
| 761 | attachments: [ |
| 762 | { |
| 763 | type: "image", |
| 764 | id: missingAttachmentId, |
| 765 | name: "outside.png", |
| 766 | mimeType: "image/png", |
| 767 | sizeBytes: 5, |
| 768 | }, |
| 769 | ], |
| 770 | }) |
| 771 | .pipe(Effect.result); |
| 772 | |
| 773 | expect(Result.isFailure(result)).toBe(true); |
| 774 | if (Result.isFailure(result)) { |
| 775 | expect(result.failure).toBeInstanceOf(TextGenerationError); |
| 776 | expect(result.failure.message).toContain("missing --image input"); |
| 777 | } |
| 778 | }), |
| 779 | ), |
| 780 | ); |
| 781 | |
| 782 | it.effect( |
| 783 | "fails with typed TextGenerationError when codex returns wrong branch payload shape", |
| 784 | () => |
| 785 | withFakeCodexEnv( |
| 786 | { |
| 787 | output: JSON.stringify({ |
| 788 | title: "This is not a branch payload", |
| 789 | }), |
| 790 | }, |
| 791 | (textGeneration) => |
| 792 | Effect.gen(function* () { |
| 793 | const result = yield* textGeneration |
| 794 | .generateBranchName({ |
| 795 | cwd: process.cwd(), |
| 796 | message: "Fix websocket reconnect flake", |
| 797 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 798 | }) |
| 799 | .pipe(Effect.result); |
| 800 | |
| 801 | expect(Result.isFailure(result)).toBe(true); |
| 802 | if (Result.isFailure(result)) { |
| 803 | expect(result.failure).toBeInstanceOf(TextGenerationError); |
| 804 | expect(result.failure.message).toContain("Codex returned invalid structured output"); |
| 805 | } |
| 806 | }), |
| 807 | ), |
| 808 | ); |
| 809 | |
| 810 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => |
| 811 | withFakeCodexEnv( |
| 812 | { |
| 813 | output: JSON.stringify({ subject: "ignored", body: "" }), |
| 814 | exitCode: 1, |
| 815 | stderr: "codex execution failed", |
| 816 | }, |
| 817 | (textGeneration) => |
| 818 | Effect.gen(function* () { |
| 819 | const result = yield* textGeneration |
| 820 | .generateCommitMessage({ |
| 821 | cwd: process.cwd(), |
| 822 | branch: "feature/codex-error", |
| 823 | stagedSummary: "M README.md", |
| 824 | stagedPatch: "diff --git a/README.md b/README.md", |
| 825 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 826 | }) |
| 827 | .pipe(Effect.result); |
| 828 | |
| 829 | expect(Result.isFailure(result)).toBe(true); |
| 830 | if (Result.isFailure(result)) { |
| 831 | expect(result.failure).toBeInstanceOf(TextGenerationError); |
| 832 | expect(result.failure.message).toContain( |
| 833 | "Codex CLI command failed: codex execution failed", |
| 834 | ); |
| 835 | } |
| 836 | }), |
| 837 | ), |
| 838 | ); |
| 839 | }); |