diff --git a/apps/server/src/codexTextGenerator.test.ts b/apps/server/src/codexTextGenerator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1d679c88c2581d1233a0aed31d41d9cbc2dfc45 --- /dev/null +++ b/apps/server/src/codexTextGenerator.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { CodexTextGenerator } from "./codexTextGenerator"; +import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; + +type ProcessRunner = ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, +) => Promise; + +function getArgValue(args: readonly string[], flag: string): string { + const index = args.indexOf(flag); + if (index < 0 || index + 1 >= args.length) { + throw new Error(`Missing argument value for ${flag}`); + } + const value = args[index + 1]; + if (!value) { + throw new Error(`Missing argument value for ${flag}`); + } + return value; +} + +function okResult(): ProcessRunResult { + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }; +} + +function commitInput() { + return { + cwd: process.cwd(), + branch: "feat/example", + stagedSummary: "M apps/server/src/gitManager.ts", + stagedPatch: "diff --git a/file b/file", + }; +} + +describe("CodexTextGenerator", () => { + it("uses gpt-5.3-codex-spark when available with medium reasoning effort", async () => { + const models: string[] = []; + const configs: string[] = []; + + const runner: ProcessRunner = async (command, args) => { + expect(command).toBe("codex"); + models.push(getArgValue(args, "--model")); + configs.push(getArgValue(args, "--config")); + + const outputPath = getArgValue(args, "--output-last-message"); + await fs.writeFile( + outputPath, + JSON.stringify({ + subject: "Add stacked git actions menu behavior", + body: "- Keep menu actions visible\n- Improve disabled states", + }), + "utf8", + ); + return okResult(); + }; + + const generator = new CodexTextGenerator({ runProcess: runner }); + const result = await generator.generateCommitMessage(commitInput()); + + expect(result.subject).toBe("Add stacked git actions menu behavior"); + expect(models).toEqual(["gpt-5.3-codex-spark"]); + expect(configs).toEqual(['model_reasoning_effort="medium"']); + }); + + it("uses gpt-5.3-codex-spark for PR content generation", async () => { + const models: string[] = []; + + const runner: ProcessRunner = async (command, args) => { + expect(command).toBe("codex"); + models.push(getArgValue(args, "--model")); + + const outputPath = getArgValue(args, "--output-last-message"); + await fs.writeFile( + outputPath, + JSON.stringify({ + title: "Improve Git action modal behavior", + body: "## Summary\n- Update PR generation model\n\n## Testing\n- Not run", + }), + "utf8", + ); + return okResult(); + }; + + const generator = new CodexTextGenerator({ runProcess: runner }); + const result = await generator.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feat/example", + commitSummary: "abc123 Update model", + diffSummary: "1 file changed", + diffPatch: "diff --git a/file b/file", + }); + + expect(result.title).toBe("Improve Git action modal behavior"); + expect(models).toEqual(["gpt-5.3-codex-spark"]); + }); + + it("propagates generation failures without retrying a second model", async () => { + const models: string[] = []; + + const runner: ProcessRunner = async (_command, args) => { + models.push(getArgValue(args, "--model")); + throw new Error("Request timed out while contacting Codex."); + }; + + const generator = new CodexTextGenerator({ runProcess: runner }); + + await expect(generator.generateCommitMessage(commitInput())).rejects.toThrow( + "Request timed out while contacting Codex.", + ); + expect(models).toEqual(["gpt-5.3-codex-spark"]); + }); +}); diff --git a/apps/server/src/codexTextGenerator.ts b/apps/server/src/codexTextGenerator.ts index 4ea6f35839eaeeff9da25ead11723b4784787388..5797c3fb40342081fe3473369cd2f65314bc363d 100644 --- a/apps/server/src/codexTextGenerator.ts +++ b/apps/server/src/codexTextGenerator.ts @@ -10,7 +10,20 @@ import type { PrContentGenerationResult, TextGenerationService, } from "./coreServices"; -import { runProcess } from "./processRunner"; +import { + type ProcessRunOptions, + type ProcessRunResult, + runProcess, +} from "./processRunner"; + +type ProcessRunner = ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, +) => Promise; + +const CODEX_MODEL = "gpt-5.3-codex-spark"; +const CODEX_REASONING_EFFORT = "medium"; const COMMIT_OUTPUT_SCHEMA_JSON = { type: "object", @@ -107,11 +120,13 @@ async function runCodexJson({ prompt, outputSchemaJson, parse, + run, }: { cwd: string; prompt: string; outputSchemaJson: object; parse: (raw: unknown) => T; + run: ProcessRunner; }): Promise { const schemaPath = await writeTempFile( "codex-schema", @@ -121,14 +136,17 @@ async function runCodexJson({ try { outputPath = await writeTempFile("codex-output", ""); - - await runProcess( + await run( "codex", [ "exec", "--ephemeral", "-s", "read-only", + "--model", + CODEX_MODEL, + "--config", + `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, "--output-schema", schemaPath, "--output-last-message", @@ -163,7 +181,17 @@ async function runCodexJson({ } } +interface CodexTextGeneratorDeps { + runProcess?: ProcessRunner; +} + export class CodexTextGenerator implements TextGenerationService { + private readonly run: ProcessRunner; + + constructor(deps: CodexTextGeneratorDeps = {}) { + this.run = deps.runProcess ?? runProcess; + } + async generateCommitMessage( input: CommitMessageGenerationInput, ): Promise { @@ -189,6 +217,7 @@ export class CodexTextGenerator implements TextGenerationService { prompt, outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, parse: (raw) => parseCommitOutput(raw), + run: this.run, }); return { @@ -227,6 +256,7 @@ export class CodexTextGenerator implements TextGenerationService { prompt, outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, parse: (raw) => parsePrOutput(raw), + run: this.run, }); return { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b9b06ed380f7c85f665eb7f0ffe5e727112d2891..bd1ef53ad0f2b85fff9e6669de59e648d2c1c6bc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -291,6 +291,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp [gitModalActionOptions, gitModalSelectedAction], ); const visibleGitActionError = gitActionError ?? gitStatusError?.message; + const gitModalOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null; const gitModalHasProgress = gitModalProgress.length > 0; const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null; const gitModalSteps = gitModalProgress; @@ -348,19 +349,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }); }, [api, gitStatus?.openPr?.url]); - const openPrFromResult = useCallback(() => { + const openPrFromModal = useCallback(() => { if (!api) { setGitModalError("Link opening is unavailable."); return; } - const prUrl = gitModalResult?.pr.url ?? null; + const prUrl = gitModalOpenPrUrl; if (!prUrl) return; void api.shell.openExternal(prUrl).catch((error) => { setGitModalError(error instanceof Error ? error.message : "Unable to open PR link."); }); - }, [api, gitModalResult?.pr.url]); + }, [api, gitModalOpenPrUrl]); const runGitAction = useCallback(async () => { if (!api || !gitCwd || !isGitModalOpen) return; @@ -784,25 +785,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp {gitModalError ?? visibleGitActionError} )} - {gitModalResult?.pr.url && ( - - )}
- {gitModalResult?.pr.url && ( + {gitModalResult && gitModalOpenPrUrl && (