1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10import * as Scope from "effect/Scope";
11import { createModelSelection } from "@t3tools/shared/model";
12import { expect } from "vite-plus/test";
13
14import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
15
16import * as ServerConfig from "../config.ts";
17import * as TextGeneration from "./TextGeneration.ts";
18import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
19import { writeFakeCli } from "@t3tools/provider-testing/fakeCli";
20const decodeCodexSettings = Schema.decodeSync(CodexSettings);
21
22const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
23 ProviderInstanceId.make("codex"),
24 "gpt-5.4-mini",
25);
26
27const layerCodexTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), {
28 prefix: "t3code-codex-text-generation-test-",
29}).pipe(Layer.provideMerge(NodeServices.layer));
30
31interface 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.
51function 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
143function 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
180it.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});