Open History to see the last commit
1import {
2 ProjectId,
3 ProviderDriverKind,
4 type ModelSelection,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
8import * as ProviderInstallationGate from "../provider/ProviderInstallationGate.ts";
9import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
10import * as ServerSettings from "../serverSettings.ts";
11import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { it } from "@effect/vitest";
14import * as Cause from "effect/Cause";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as Fiber from "effect/Fiber";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22import * as Scope from "effect/Scope";
23import { createModelSelection } from "@t3tools/shared/model";
24import { expect } from "vite-plus/test";
25
26import { CodexSettings, ProviderInstanceId } from "@t3tools/contracts";
27
28import * as ServerConfig from "../config.ts";
29import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts";
30import * as TextGeneration from "./TextGeneration.ts";
31import * as TextGenerators from "./TextGenerators.ts";
32import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
33import { writeFakeCli } from "@t3tools/provider-testing/fakeCli";
34const decodeCodexSettings = Schema.decodeSync(CodexSettings);
35
36const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
37 ProviderInstanceId.make("codex"),
38 "gpt-5.4-mini",
39);
40
41const layerCodexTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), {
42 prefix: "t3code-codex-text-generation-test-",
43}).pipe(Layer.provideMerge(NodeServices.layer));
44
45interface FakeCodexInput {
46 output: string;
47 lifecycle?: { port: number; command: "exec" | "mcp list" };
48 exitCode?: number;
49 stderr?: string;
50 requireImage?: boolean;
51 requireServiceTier?: string;
52 requireReasoningEffort?: string;
53 forbidReasoningEffort?: boolean;
54 requireArg?: string;
55 forbidArg?: string;
56 stdinMustContain?: string;
57 stdinMustNotContain?: string;
58 mcpServers?: ReadonlyArray<{ name: string; enabled: boolean }> | number;
59 requiredCwd?: string;
60 requireInstructions?: string;
61}
62
63function makeFakeCodexBinary(dir: string, input: FakeCodexInput) {
64 const check = JSON.stringify({
65 requireImage: input.requireImage ?? false,
66 requireServiceTier: input.requireServiceTier ?? null,
67 requireReasoningEffort: input.requireReasoningEffort ?? null,
68 forbidReasoningEffort: input.forbidReasoningEffort ?? false,
69 requireArg: input.requireArg ?? null,
70 forbidArg: input.forbidArg ?? null,
71 stdinMustContain: input.stdinMustContain ?? null,
72 stdinMustNotContain: input.stdinMustNotContain ?? null,
73 mcpServers: input.mcpServers ?? [],
74 requiredCwd: input.requiredCwd ?? null,
75 requireInstructions: input.requireInstructions ?? null,
76 stderr: input.stderr ?? null,
77 output: input.output,
78 exitCode: input.exitCode ?? 0,
79 });
80 return Effect.gen(function* () {
81 const path = yield* Path.Path;
82 return writeFakeCli({
83 directory: path.join(dir, "bin"),
84 name: "codex",
85 source: [
86 'import * as NodeFS from "node:fs";',
87 `const check = ${check};`,
88 "const args = process.argv.slice(2);",
89 'const isMcpList = args.includes("mcp") && args.includes("list");',
90 ...(input.lifecycle === undefined
91 ? []
92 : [
93 `if (isMcpList === ${input.lifecycle.command === "mcp list"}) {`,
94 'process.on("SIGTERM", () => {});',
95 processProbeSource(input.lifecycle.port),
96 "await new Promise(() => {});",
97 "}",
98 ]),
99 "if (isMcpList) {",
100 ' if (typeof check.mcpServers === "number") process.exit(check.mcpServers);',
101 " process.stdout.write(JSON.stringify(check.mcpServers));",
102 " process.exit(0);",
103 "}",
104 'const originalArgs = ` ${args.join(" ")} `;',
105 "let outputPath = null;",
106 "let seenImage = false;",
107 'let seenServiceTier = "";',
108 'let seenReasoningEffort = "";',
109 'let instructions = "";',
110 "for (let index = 0; index < args.length; index += 1) {",
111 ' if (args[index] === "--image") {',
112 " index += 1;",
113 " if (args[index]) seenImage = true;",
114 ' } else if (args[index] === "--config") {',
115 " index += 1;",
116 ' const value = args[index] ?? "";',
117 ' if (value.startsWith("developer_instructions=")) instructions = JSON.parse(value.slice("developer_instructions=".length));',
118 ' if (value.startsWith("service_tier=")) seenServiceTier = value;',
119 ' if (value.startsWith("model_reasoning_effort=")) seenReasoningEffort = value;',
120 ' } else if (args[index] === "--output-last-message") {',
121 " index += 1;",
122 " outputPath = args[index] ?? null;",
123 " }",
124 "}",
125 "const chunks = [];",
126 "for await (const chunk of process.stdin) chunks.push(chunk);",
127 'const stdinContent = Buffer.concat(chunks).toString("utf8");',
128 "function fail(message, code) {",
129 ' process.stderr.write(message + "\\n");',
130 " process.exit(code);",
131 "}",
132 'if (check.requiredCwd !== null && process.cwd() !== check.requiredCwd) fail("cannot inspect checkout", 10);',
133 'if (check.requireInstructions !== null && !instructions.includes(check.requireInstructions)) fail("missing writer task", 11);',
134 "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {",
135 ' fail("missing arg: " + check.requireArg, 8);',
136 "}",
137 "if (check.forbidArg !== null && originalArgs.includes(` ${check.forbidArg} `)) {",
138 ' fail("forbidden arg: " + check.forbidArg, 9);',
139 "}",
140 'if (check.requireImage && !seenImage) fail("missing --image input", 2);',
141 "if (",
142 " check.requireServiceTier !== null &&",
143 ' seenServiceTier !== `service_tier="${check.requireServiceTier}"`',
144 ") {",
145 ' fail("unexpected service tier config: " + seenServiceTier, 5);',
146 "}",
147 "if (",
148 " check.requireReasoningEffort !== null &&",
149 ' seenReasoningEffort !== `model_reasoning_effort="${check.requireReasoningEffort}"`',
150 ") {",
151 ' fail("unexpected reasoning effort config: " + seenReasoningEffort, 6);',
152 "}",
153 "if (check.forbidReasoningEffort && seenReasoningEffort.length > 0) {",
154 ' fail("reasoning effort config should be omitted: " + seenReasoningEffort, 7);',
155 "}",
156 "if (check.stdinMustContain !== null && !stdinContent.includes(check.stdinMustContain)) {",
157 ' fail("stdin missing expected content", 3);',
158 "}",
159 "if (check.stdinMustNotContain !== null && stdinContent.includes(check.stdinMustNotContain)) {",
160 ' fail("stdin contained forbidden content", 4);',
161 "}",
162 'if (check.stderr !== null) process.stderr.write(check.stderr + "\\n");',
163 'if (outputPath !== null) NodeFS.writeFileSync(outputPath, check.output + "\\n");',
164 "process.exitCode = check.exitCode;",
165 "",
166 ].join("\n"),
167 });
168 });
169}
170
171function withFakeCodexEnv<A, E, R>(
172 input: FakeCodexInput & {
173 launchArgs?: string;
174 environment?: NodeJS.ProcessEnv;
175 models?: ReadonlyArray<string>;
176 defaultModel?: string;
177 customModels?: ReadonlyArray<string>;
178 managedRuntime?: boolean;
179 },
180 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
181) {
182 return Effect.gen(function* () {
183 const fs = yield* FileSystem.FileSystem;
184 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
185 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
186 const config = decodeCodexSettings({
187 binaryPath: codexPath,
188 launchArgs: input.launchArgs,
189 customModels: input.customModels,
190 });
191 const textGeneration = yield* makeCodexTextGeneration(
192 config,
193 input.environment === undefined ? undefined : { ...process.env, ...input.environment },
194 Effect.succeed(
195 (input.models ?? []).map((slug) => ({
196 slug,
197 name: slug,
198 isCustom: false,
199 ...(slug === input.defaultModel ? { isDefault: true } : {}),
200 capabilities: null,
201 })),
202 ),
203 input.managedRuntime
204 ? Effect.succeed({
205 config,
206 environment: input.environment ?? process.env,
207 revision: "test",
208 })
209 : undefined,
210 );
211 return yield* effectFn(textGeneration);
212 }).pipe(Effect.scoped);
213}
214
215it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => {
216 it.effect.each(["exec", "mcp list"] as const)(
217 "kills a stalled %s CLI when its request is interrupted",
218 (command) =>
219 Effect.gen(function* () {
220 const probe = yield* makeProcessProbe;
221 yield* withFakeCodexEnv(
222 { output: "", lifecycle: { port: probe.port, command } },
223 (textGeneration) =>
224 Effect.gen(function* () {
225 const fiber = yield* textGeneration
226 .generateThreadTitle({
227 cwd: process.cwd(),
228 message: "Name this thread",
229 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
230 })
231 .pipe(Effect.forkScoped);
232 yield* probe.started;
233
234 const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkScoped);
235 yield* probe.exited;
236 yield* Fiber.join(interrupted);
237 const exit = yield* Fiber.await(fiber);
238 expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true);
239 }),
240 );
241 }),
242 );
243
244 it.effect.each([
245 {
246 selectedModel: "gpt-5.6-luna",
247 models: ["openai.gpt-5.6-luna"],
248 expected: "openai.gpt-5.6-luna",
249 },
250 {
251 selectedModel: "openai.gpt-5.6-luna",
252 models: ["openai.gpt-5.6-luna"],
253 expected: "openai.gpt-5.6-luna",
254 },
255 {
256 selectedModel: "openai.gpt-5.6-luna",
257 models: ["gpt-6-astra", "gpt-5.6-luna"],
258 defaultModel: "gpt-6-astra",
259 expected: "gpt-5.6-luna",
260 },
261 {
262 selectedModel: "gpt-6-luna",
263 models: ["gpt-5.6-luna", "gpt-6-astra"],
264 defaultModel: "gpt-6-astra",
265 expected: "gpt-6-astra",
266 },
267 { selectedModel: "gpt-6-luna", models: ["gpt-5.6-luna"], expected: "gpt-5.6-luna" },
268 { selectedModel: "gpt-6-luna", models: [], expected: "gpt-6-luna" },
269 ])(
270 "dispatches $expected for $selectedModel from $models",
271 ({ selectedModel, models, defaultModel, expected }) =>
272 withFakeCodexEnv(
273 {
274 output: JSON.stringify({ title: "Live title" }),
275 models,
276 ...(defaultModel ? { defaultModel } : {}),
277 requireArg: `--model ${expected}`,
278 },
279 (textGeneration) =>
280 Effect.gen(function* () {
281 const result = yield* textGeneration.generateThreadTitle({
282 cwd: process.cwd(),
283 message: "Describe this change",
284 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), selectedModel),
285 });
286 expect(result.title).toBe("Live title");
287 }),
288 ),
289 );
290
291 it.effect("drops the requested model's options when falling back to another model", () =>
292 withFakeCodexEnv(
293 {
294 output: JSON.stringify({ title: "Live title" }),
295 models: ["gpt-6-astra"],
296 requireArg: '--model gpt-6-astra --config model_reasoning_effort="low"',
297 forbidArg: 'service_tier="priority"',
298 },
299 (textGeneration) =>
300 textGeneration.generateThreadTitle({
301 cwd: process.cwd(),
302 message: "Describe this change",
303 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-6-luna", [
304 { id: "reasoningEffort", value: "none" },
305 { id: "serviceTier", value: "priority" },
306 ]),
307 }),
308 ),
309 );
310
311 it.effect("sends a configured custom model the managed snapshot leaves out", () =>
312 withFakeCodexEnv(
313 {
314 output: JSON.stringify({ title: "Live title" }),
315 models: ["gpt-6-astra", "gpt-5.6-luna"],
316 defaultModel: "gpt-6-astra",
317 customModels: ["gpt-5.6-luna-preview"],
318 managedRuntime: true,
319 requireArg: '--model gpt-5.6-luna-preview --config model_reasoning_effort="high"',
320 },
321 (textGeneration) =>
322 textGeneration.generateThreadTitle({
323 cwd: process.cwd(),
324 message: "Describe this change",
325 modelSelection: createModelSelection(
326 ProviderInstanceId.make("codex"),
327 "gpt-5.6-luna-preview",
328 [{ id: "reasoningEffort", value: "high" }],
329 ),
330 }),
331 ),
332 );
333
334 it.effect("omits a persisted service tier for managed ChatGPT text generation", () =>
335 withFakeCodexEnv(
336 {
337 output: JSON.stringify({ subject: "Update project", body: "" }),
338 managedRuntime: true,
339 forbidArg: 'service_tier="priority"',
340 },
341 (textGeneration) =>
342 textGeneration.generateCommitMessage({
343 cwd: process.cwd(),
344 branch: "feature/chatgpt",
345 stagedSummary: "M README.md",
346 stagedPatch: "diff --git a/README.md b/README.md",
347 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
348 { id: "serviceTier", value: "priority" },
349 ]),
350 }),
351 ),
352 );
353
354 it.effect("preserves custom PR instructions while allowing checkout inspection", () =>
355 withFakeCodexEnv(
356 {
357 output: JSON.stringify({ title: "Remove default shapes", body: "Removes default shapes." }),
358 launchArgs: "--config 'developer_instructions=\"Use TEAM: as the PR title prefix\"'",
359 requiredCwd: process.cwd(),
360 requireInstructions: "Use TEAM: as the PR title prefix",
361 forbidArg: "--config features.shell_tool=false",
362 mcpServers: [
363 { name: "augmenter", enabled: true },
364 { name: "my.server", enabled: true },
365 { name: "already-off", enabled: false },
366 ],
367 requireArg:
368 '--config features.plugins=false --config features.apps=false --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}}',
369 },
370 (textGeneration) =>
371 Effect.gen(function* () {
372 const result = yield* textGeneration.generatePrContent({
373 cwd: process.cwd(),
374 baseBranch: "main",
375 headBranch: "remove-default-shapes",
376 commitSummary: "Remove default shapes",
377 diffSummary: "shapes.ts | 2 --",
378 diffPatch: "-addDefaultShapes();",
379 threadContext: "user: in a few minutes PR will be up then $babysit-pr",
380 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
381 });
382 expect(result.title).toBe("Remove default shapes");
383 }),
384 ),
385 );
386
387 it.effect("still generates when Codex cannot list its MCP servers", () =>
388 withFakeCodexEnv(
389 {
390 output: JSON.stringify({ branch: "remove-default-shapes" }),
391 mcpServers: 2,
392 requireArg: "--config features.plugins=false --config features.apps=false --ephemeral",
393 forbidArg: "mcp_servers=",
394 },
395 (textGeneration) =>
396 Effect.gen(function* () {
397 const result = yield* textGeneration.generateBranchName({
398 cwd: process.cwd(),
399 message: "Remove default shapes from characters",
400 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
401 });
402 expect(result.branch).toBe("remove-default-shapes");
403 }),
404 ),
405 );
406
407 it.effect("keeps plugins and apps off over launch args that turn them on", () =>
408 withFakeCodexEnv(
409 {
410 output: JSON.stringify({ title: "Remove default shapes" }),
411 launchArgs: "--config features.plugins=true --config features.apps=true",
412 requireArg:
413 "--config features.plugins=true --config features.apps=true --config features.plugins=false --config features.apps=false --ephemeral",
414 },
415 (textGeneration) =>
416 Effect.gen(function* () {
417 const result = yield* textGeneration.generateThreadTitle({
418 cwd: process.cwd(),
419 message: "Remove default shapes from characters",
420 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
421 });
422 expect(result.title).toBe("Remove default shapes");
423 }),
424 ),
425 );
426
427 it.effect("passes exec-safe launch args into codex exec", () =>
428 withFakeCodexEnv(
429 {
430 output: JSON.stringify({
431 subject: "Add important change",
432 body: "",
433 }),
434 launchArgs: "--strict-config --oss --local-provider ollama --listen off",
435 requireArg: "--strict-config --oss --local-provider ollama",
436 forbidArg: "--listen",
437 },
438 (textGeneration) =>
439 textGeneration.generateCommitMessage({
440 cwd: process.cwd(),
441 branch: "feature/codex-effect",
442 stagedSummary: "M README.md",
443 stagedPatch: "diff --git a/README.md b/README.md",
444 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
445 }),
446 ),
447 );
448
449 it.effect("generates branch names even when the ambient scope is already closed", () =>
450 withFakeCodexEnv(
451 {
452 output: JSON.stringify({
453 branch: "feat/background-generation",
454 }),
455 },
456 (textGeneration) =>
457 Effect.gen(function* () {
458 // Background fibers can outlive request scopes; temp files scoped there would be reaped on creation.
459 const closedScope = yield* Scope.make();
460 yield* Scope.close(closedScope, Exit.void);
461
462 const generated = yield* textGeneration
463 .generateBranchName({
464 cwd: process.cwd(),
465 message: "Please update session handling.",
466 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
467 })
468 .pipe(Effect.provideService(Scope.Scope, closedScope));
469
470 expect(generated.branch).toBe("feat/background-generation");
471 }),
472 ),
473 );
474
475 it.effect("returns the refinement signal for an unresolved subject", () =>
476 withFakeCodexEnv(
477 { output: JSON.stringify({ title: "Investigate issue", needsRefinement: true }) },
478 (textGeneration) =>
479 Effect.gen(function* () {
480 expect(
481 yield* textGeneration.generateThreadTitle({
482 cwd: process.cwd(),
483 message: "Fix this",
484 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
485 }),
486 ).toEqual({ title: "Investigate issue", needsRefinement: true });
487 }),
488 ),
489 );
490
491 it.effect("resolves persisted attachment ids to files for codex image inputs", () =>
492 withFakeCodexEnv(
493 {
494 output: JSON.stringify({
495 branch: "fix/ui-regression",
496 }),
497 requireImage: true,
498 },
499 (textGeneration) =>
500 Effect.gen(function* () {
501 const fs = yield* FileSystem.FileSystem;
502 const path = yield* Path.Path;
503 const { attachmentsDir } = yield* ServerConfig.ServerConfig;
504 const attachmentId = "thread-1-attachment";
505 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);
506 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
507 yield* fs.writeFile(imagePath, Buffer.from("hello"));
508
509 const generated = yield* textGeneration
510 .generateBranchName({
511 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
512 cwd: process.cwd(),
513 message: "Fix layout bug from screenshot.",
514 attachments: [
515 {
516 type: "image",
517 id: attachmentId,
518 name: "bug.png",
519 mimeType: "image/png",
520 sizeBytes: 5,
521 },
522 ],
523 })
524 .pipe(
525 Effect.tap(() =>
526 fs.stat(imagePath).pipe(
527 Effect.map((fileInfo) => {
528 expect(fileInfo.type).toBe("File");
529 }),
530 ),
531 ),
532 Effect.ensuring(fs.remove(imagePath).pipe(Effect.ignore)),
533 );
534
535 expect(generated.branch).toBe("fix/ui-regression");
536 }),
537 ),
538 );
539});
540
541const PREFLIGHT_PRIMARY_STATES = [
542 "ready",
543 "pending",
544 "exhausted",
545 "not installed",
546 "not installed, fallback disabled for project",
547 "signed out",
548 "signed out, fallback failed",
549 "cannot generate text",
550 "model cannot generate text",
551] as const;
552
553it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (primaryState) =>
554 Effect.gen(function* () {
555 const primary = ProviderInstanceId.make("codex");
556 const fallback = ProviderInstanceId.make("claude_work");
557 const projectId = ProjectId.make("project-a");
558 const selection = {
559 instanceId: primary,
560 model: "chat-model",
561 options: [{ id: "effort", value: "high" }],
562 };
563 const threadDefault = {
564 instanceId: fallback,
565 model: "z-ai/glm-5.3-flash",
566 options: [{ id: "thinking", value: false }],
567 };
568 const calls: ModelSelection[] = [];
569 const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => {
570 const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent");
571 const snapshot: ServerProvider = {
572 instanceId,
573 driver,
574 enabled: true,
575 installed: true,
576 status: "ready",
577 auth: { status: "authenticated" },
578 version: null,
579 checkedAt: "1970-01-01T00:00:00Z",
580 models: [],
581 slashCommands: [],
582 skills: [],
583 ...(primaryState === "pending" && instanceId === primary
584 ? { installed: false, status: "warning", auth: { status: "unknown" } }
585 : {}),
586 ...(primaryState.startsWith("not installed") && instanceId === primary
587 ? { installed: false, status: "error", auth: { status: "unknown" } }
588 : {}),
589 ...(primaryState.startsWith("signed out") && instanceId === primary
590 ? { status: "error", auth: { status: "unauthenticated" } }
591 : {}),
592 ...(primaryState === "signed out, fallback failed" && instanceId === fallback
593 ? { status: "error", auth: { status: "unknown" } }
594 : {}),
595 ...(primaryState === "cannot generate text" && instanceId === primary
596 ? { supportsTextGeneration: false }
597 : {}),
598 ...(primaryState === "model cannot generate text" && instanceId === primary
599 ? {
600 models: [
601 {
602 slug: selection.model,
603 name: selection.model,
604 isCustom: false,
605 supportsTextGeneration: false,
606 capabilities: null,
607 },
608 ],
609 }
610 : {}),
611 ...(primaryState === "exhausted" && instanceId === primary
612 ? {
613 usageLimits: {
614 checkedAt: "1970-01-01T00:00:00Z",
615 windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }],
616 },
617 }
618 : {}),
619 };
620 return {
621 instanceId,
622 driverKind: driver,
623 continuationIdentity: { driverKind: driver, continuationKey: instanceId },
624 displayName: undefined,
625 enabled: true,
626 snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"],
627 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
628 textGeneration: TextGeneration.TextGeneration.of({
629 generateCommitMessage: () => Effect.die("unused"),
630 generatePrContent: () => Effect.die("unused"),
631 generateBranchName: () => Effect.die("unused"),
632 generateReviewGuide: () => Effect.die("unused"),
633 generateTranscriptionPostProcessing: () => Effect.die("unused"),
634 generateThreadTitle: (input) =>
635 Effect.sync(() => {
636 calls.push(input.modelSelection);
637 return { title: "Generated title" };
638 }),
639 }),
640 };
641 });
642 const service = yield* TextGeneration.make.pipe(
643 Effect.provide(
644 Layer.mergeAll(
645 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
646 listInstances: Effect.succeed(instances),
647 getInstance: (id) =>
648 Effect.succeed(instances.find((instance) => instance.instanceId === id)),
649 }),
650 ServerSettings.layerTest({
651 providerInstances: {
652 [fallback]: {
653 driver: ProviderDriverKind.make("claudeAgent"),
654 config: { customModels: [threadDefault.model] },
655 },
656 },
657 defaultModelSelection: threadDefault,
658 ...(primaryState === "not installed, fallback disabled for project"
659 ? {
660 projectSettingsOverrides: {
661 [projectId]: { providerInstances: { [fallback]: { enabled: false } } },
662 },
663 }
664 : {}),
665 }),
666 Layer.mock(TextGenerators.TextGenerators)({ runner: () => Effect.undefined }),
667 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
668 resolveLink: () => Effect.die("unused"),
669 }),
670 ProviderInstallationGate.layer,
671 ),
672 ),
673 );
674 expect(
675 yield* service.generateThreadTitle({
676 cwd: "/tmp",
677 message: "hello",
678 linkedContext: "",
679 modelSelection: selection,
680 projectId,
681 }),
682 ).toEqual({ title: "Generated title" });
683 expect(calls).toEqual([
684 primaryState === "ready" ||
685 primaryState === "pending" ||
686 primaryState === "not installed, fallback disabled for project" ||
687 primaryState === "signed out, fallback failed"
688 ? selection
689 : threadDefault,
690 ]);
691 }),
692);
693
694it.effect(
695 "a review guide aimed at a text generator falls back to an agent that can read the checkout",
696 () =>
697 Effect.gen(function* () {
698 const claude = ProviderInstanceId.make("claudeAgent");
699 const driver = ProviderDriverKind.make("claudeAgent");
700 const calls: ModelSelection[] = [];
701 const instance = {
702 instanceId: claude,
703 driverKind: driver,
704 continuationIdentity: { driverKind: driver, continuationKey: claude },
705 displayName: undefined,
706 enabled: true,
707 snapshot: {
708 getSnapshot: Effect.succeed<ServerProvider>({
709 instanceId: claude,
710 driver,
711 enabled: true,
712 installed: true,
713 status: "ready",
714 auth: { status: "authenticated" },
715 version: null,
716 checkedAt: "1970-01-01T00:00:00Z",
717 models: [],
718 slashCommands: [],
719 skills: [],
720 }),
721 } as ProviderInstance["snapshot"],
722 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
723 textGeneration: TextGeneration.TextGeneration.of({
724 generateCommitMessage: () => Effect.die("unused"),
725 generatePrContent: () => Effect.die("unused"),
726 generateBranchName: () => Effect.die("unused"),
727 generateThreadTitle: () => Effect.die("unused"),
728 generateTranscriptionPostProcessing: () => Effect.die("unused"),
729 generateReviewGuide: (input) =>
730 Effect.sync(() => {
731 calls.push(input.modelSelection);
732 return {} as TextGeneration.ReviewGuideGenerationResult;
733 }),
734 }),
735 } satisfies ProviderInstance;
736 const serviceWith = (instances: ReadonlyArray<ProviderInstance>) =>
737 TextGeneration.make.pipe(
738 Effect.provide(
739 Layer.mergeAll(
740 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
741 listInstances: Effect.succeed(instances),
742 getInstance: (id) =>
743 Effect.succeed(instances.find((candidate) => candidate.instanceId === id)),
744 }),
745 ServerSettings.layerTest({}),
746 Layer.mock(TextGenerators.TextGenerators)({
747 runner: (id) =>
748 Effect.succeed(
749 id === "openrouter"
750 ? () => Effect.die("a text generator cannot investigate")
751 : undefined,
752 ),
753 }),
754 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
755 resolveLink: () => Effect.die("unused"),
756 }),
757 ProviderInstallationGate.layer,
758 ),
759 ),
760 );
761 const input = {
762 cwd: "/tmp",
763 number: 1,
764 title: "Change",
765 description: "",
766 baseBranch: "main",
767 baseCommit: "abc",
768 settledReview: "",
769 modelSelection: { instanceId: ProviderInstanceId.make("openrouter"), model: "some/model" },
770 };
771 yield* (yield* serviceWith([instance])).generateReviewGuide(input);
772 expect(calls.map((selection) => selection.instanceId)).toEqual([claude]);
773
774 // With no agent ready, it says what is missing instead of looking up a provider by that id.
775 const failure = yield* Effect.flip((yield* serviceWith([])).generateReviewGuide(input));
776 expect(failure.detail).toMatch(
777 /^Review guides need a coding agent that can read the checkout/,
778 );
779 }),
780);