authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 14:42:15-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 14:42:15-08:00
log8f6cd244e79913b4b55daf8ed14c7573726dfe62
tree9bccf01aa9988cb71992bcd49f9e684af854275f
parent95c8463706b26e76701d78f8e3ce9e1eb0622e0f

Use gpt-5.3-codex-spark for git text generation

- Add `gpt-5.3-codex-spark` with medium reasoning effort to commit/PR generation calls - Inject process runner into `CodexTextGenerator` and add tests for model selection and failure passthrough - Update PR modal to use status/result PR URLs and keep a single Open PR action path

3 files changed, 162 insertions(+), 21 deletions(-)

apps/server/src/codexTextGenerator.test.ts created+123
...@@ -0,0 +1,123 @@
1import fs from "node:fs/promises";
2
3import { describe, expect, it } from "vitest";
4
5import { CodexTextGenerator } from "./codexTextGenerator";
6import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";
7
8type ProcessRunner = (
9 command: string,
10 args: readonly string[],
11 options?: ProcessRunOptions,
12) => Promise<ProcessRunResult>;
13
14function getArgValue(args: readonly string[], flag: string): string {
15 const index = args.indexOf(flag);
16 if (index < 0 || index + 1 >= args.length) {
17 throw new Error(`Missing argument value for ${flag}`);
18 }
19 const value = args[index + 1];
20 if (!value) {
21 throw new Error(`Missing argument value for ${flag}`);
22 }
23 return value;
24}
25
26function okResult(): ProcessRunResult {
27 return {
28 stdout: "",
29 stderr: "",
30 code: 0,
31 signal: null,
32 timedOut: false,
33 };
34}
35
36function commitInput() {
37 return {
38 cwd: process.cwd(),
39 branch: "feat/example",
40 stagedSummary: "M apps/server/src/gitManager.ts",
41 stagedPatch: "diff --git a/file b/file",
42 };
43}
44
45describe("CodexTextGenerator", () => {
46 it("uses gpt-5.3-codex-spark when available with medium reasoning effort", async () => {
47 const models: string[] = [];
48 const configs: string[] = [];
49
50 const runner: ProcessRunner = async (command, args) => {
51 expect(command).toBe("codex");
52 models.push(getArgValue(args, "--model"));
53 configs.push(getArgValue(args, "--config"));
54
55 const outputPath = getArgValue(args, "--output-last-message");
56 await fs.writeFile(
57 outputPath,
58 JSON.stringify({
59 subject: "Add stacked git actions menu behavior",
60 body: "- Keep menu actions visible\n- Improve disabled states",
61 }),
62 "utf8",
63 );
64 return okResult();
65 };
66
67 const generator = new CodexTextGenerator({ runProcess: runner });
68 const result = await generator.generateCommitMessage(commitInput());
69
70 expect(result.subject).toBe("Add stacked git actions menu behavior");
71 expect(models).toEqual(["gpt-5.3-codex-spark"]);
72 expect(configs).toEqual(['model_reasoning_effort="medium"']);
73 });
74
75 it("uses gpt-5.3-codex-spark for PR content generation", async () => {
76 const models: string[] = [];
77
78 const runner: ProcessRunner = async (command, args) => {
79 expect(command).toBe("codex");
80 models.push(getArgValue(args, "--model"));
81
82 const outputPath = getArgValue(args, "--output-last-message");
83 await fs.writeFile(
84 outputPath,
85 JSON.stringify({
86 title: "Improve Git action modal behavior",
87 body: "## Summary\n- Update PR generation model\n\n## Testing\n- Not run",
88 }),
89 "utf8",
90 );
91 return okResult();
92 };
93
94 const generator = new CodexTextGenerator({ runProcess: runner });
95 const result = await generator.generatePrContent({
96 cwd: process.cwd(),
97 baseBranch: "main",
98 headBranch: "feat/example",
99 commitSummary: "abc123 Update model",
100 diffSummary: "1 file changed",
101 diffPatch: "diff --git a/file b/file",
102 });
103
104 expect(result.title).toBe("Improve Git action modal behavior");
105 expect(models).toEqual(["gpt-5.3-codex-spark"]);
106 });
107
108 it("propagates generation failures without retrying a second model", async () => {
109 const models: string[] = [];
110
111 const runner: ProcessRunner = async (_command, args) => {
112 models.push(getArgValue(args, "--model"));
113 throw new Error("Request timed out while contacting Codex.");
114 };
115
116 const generator = new CodexTextGenerator({ runProcess: runner });
117
118 await expect(generator.generateCommitMessage(commitInput())).rejects.toThrow(
119 "Request timed out while contacting Codex.",
120 );
121 expect(models).toEqual(["gpt-5.3-codex-spark"]);
122 });
123});
apps/server/src/codexTextGenerator.ts+33-3
...@@ -10,7 +10,20 @@ import type {...@@ -10,7 +10,20 @@ import type {
10 PrContentGenerationResult,10 PrContentGenerationResult,
11 TextGenerationService,11 TextGenerationService,
12} from "./coreServices";12} from "./coreServices";
13import { runProcess } from "./processRunner";13import {
14 type ProcessRunOptions,
15 type ProcessRunResult,
16 runProcess,
17} from "./processRunner";
18
19type ProcessRunner = (
20 command: string,
21 args: readonly string[],
22 options?: ProcessRunOptions,
23) => Promise<ProcessRunResult>;
24
25const CODEX_MODEL = "gpt-5.3-codex-spark";
26const CODEX_REASONING_EFFORT = "medium";
1427
15const COMMIT_OUTPUT_SCHEMA_JSON = {28const COMMIT_OUTPUT_SCHEMA_JSON = {
16 type: "object",29 type: "object",
...@@ -107,11 +120,13 @@ async function runCodexJson<T>({...@@ -107,11 +120,13 @@ async function runCodexJson<T>({
107 prompt,120 prompt,
108 outputSchemaJson,121 outputSchemaJson,
109 parse,122 parse,
123 run,
110}: {124}: {
111 cwd: string;125 cwd: string;
112 prompt: string;126 prompt: string;
113 outputSchemaJson: object;127 outputSchemaJson: object;
114 parse: (raw: unknown) => T;128 parse: (raw: unknown) => T;
129 run: ProcessRunner;
115}): Promise<T> {130}): Promise<T> {
116 const schemaPath = await writeTempFile(131 const schemaPath = await writeTempFile(
117 "codex-schema",132 "codex-schema",
...@@ -121,14 +136,17 @@ async function runCodexJson<T>({...@@ -121,14 +136,17 @@ async function runCodexJson<T>({
121136
122 try {137 try {
123 outputPath = await writeTempFile("codex-output", "");138 outputPath = await writeTempFile("codex-output", "");
124139 await run(
125 await runProcess(
126 "codex",140 "codex",
127 [141 [
128 "exec",142 "exec",
129 "--ephemeral",143 "--ephemeral",
130 "-s",144 "-s",
131 "read-only",145 "read-only",
146 "--model",
147 CODEX_MODEL,
148 "--config",
149 `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
132 "--output-schema",150 "--output-schema",
133 schemaPath,151 schemaPath,
134 "--output-last-message",152 "--output-last-message",
...@@ -163,7 +181,17 @@ async function runCodexJson<T>({...@@ -163,7 +181,17 @@ async function runCodexJson<T>({
163 }181 }
164}182}
165183
184interface CodexTextGeneratorDeps {
185 runProcess?: ProcessRunner;
186}
187
166export class CodexTextGenerator implements TextGenerationService {188export class CodexTextGenerator implements TextGenerationService {
189 private readonly run: ProcessRunner;
190
191 constructor(deps: CodexTextGeneratorDeps = {}) {
192 this.run = deps.runProcess ?? runProcess;
193 }
194
167 async generateCommitMessage(195 async generateCommitMessage(
168 input: CommitMessageGenerationInput,196 input: CommitMessageGenerationInput,
169 ): Promise<CommitMessageGenerationResult> {197 ): Promise<CommitMessageGenerationResult> {
...@@ -189,6 +217,7 @@ export class CodexTextGenerator implements TextGenerationService {...@@ -189,6 +217,7 @@ export class CodexTextGenerator implements TextGenerationService {
189 prompt,217 prompt,
190 outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON,218 outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON,
191 parse: (raw) => parseCommitOutput(raw),219 parse: (raw) => parseCommitOutput(raw),
220 run: this.run,
192 });221 });
193222
194 return {223 return {
...@@ -227,6 +256,7 @@ export class CodexTextGenerator implements TextGenerationService {...@@ -227,6 +256,7 @@ export class CodexTextGenerator implements TextGenerationService {
227 prompt,256 prompt,
228 outputSchemaJson: PR_OUTPUT_SCHEMA_JSON,257 outputSchemaJson: PR_OUTPUT_SCHEMA_JSON,
229 parse: (raw) => parsePrOutput(raw),258 parse: (raw) => parsePrOutput(raw),
259 run: this.run,
230 });260 });
231261
232 return {262 return {
apps/web/src/components/GitActionsControl.tsx+6-18
...@@ -291,6 +291,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -291,6 +291,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
291 [gitModalActionOptions, gitModalSelectedAction],291 [gitModalActionOptions, gitModalSelectedAction],
292 );292 );
293 const visibleGitActionError = gitActionError ?? gitStatusError?.message;293 const visibleGitActionError = gitActionError ?? gitStatusError?.message;
294 const gitModalOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null;
294 const gitModalHasProgress = gitModalProgress.length > 0;295 const gitModalHasProgress = gitModalProgress.length > 0;
295 const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null;296 const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null;
296 const gitModalSteps = gitModalProgress;297 const gitModalSteps = gitModalProgress;
...@@ -348,19 +349,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -348,19 +349,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
348 });349 });
349 }, [api, gitStatus?.openPr?.url]);350 }, [api, gitStatus?.openPr?.url]);
350351
351 const openPrFromResult = useCallback(() => {352 const openPrFromModal = useCallback(() => {
352 if (!api) {353 if (!api) {
353 setGitModalError("Link opening is unavailable.");354 setGitModalError("Link opening is unavailable.");
354 return;355 return;
355 }356 }
356357
357 const prUrl = gitModalResult?.pr.url ?? null;358 const prUrl = gitModalOpenPrUrl;
358 if (!prUrl) return;359 if (!prUrl) return;
359360
360 void api.shell.openExternal(prUrl).catch((error) => {361 void api.shell.openExternal(prUrl).catch((error) => {
361 setGitModalError(error instanceof Error ? error.message : "Unable to open PR link.");362 setGitModalError(error instanceof Error ? error.message : "Unable to open PR link.");
362 });363 });
363 }, [api, gitModalResult?.pr.url]);364 }, [api, gitModalOpenPrUrl]);
364365
365 const runGitAction = useCallback(async () => {366 const runGitAction = useCallback(async () => {
366 if (!api || !gitCwd || !isGitModalOpen) return;367 if (!api || !gitCwd || !isGitModalOpen) return;
...@@ -784,25 +785,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -784,25 +785,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
784 {gitModalError ?? visibleGitActionError}785 {gitModalError ?? visibleGitActionError}
785 </div>786 </div>
786 )}787 )}
787 {gitModalResult?.pr.url && (
788 <div className="mt-4 rounded-lg border border-border bg-card/40 px-3 py-2 text-xs text-muted-foreground/80">
789 PR:{" "}
790 <a
791 href={gitModalResult.pr.url}
792 target="_blank"
793 rel="noreferrer"
794 className="text-foreground underline underline-offset-2"
795 >
796 {gitModalResult.pr.url}
797 </a>
798 </div>
799 )}
800 <div className="mt-6 flex justify-end gap-2">788 <div className="mt-6 flex justify-end gap-2">
801 {gitModalResult?.pr.url && (789 {gitModalResult && gitModalOpenPrUrl && (
802 <button790 <button
803 type="button"791 type="button"
804 className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"792 className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
805 onClick={openPrFromResult}793 onClick={openPrFromModal}
806 disabled={isGitActionRunning}794 disabled={isGitActionRunning}
807 >795 >
808 Open PR796 Open PR