authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 23:43:32-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
log5e0ec383838ee95a60d40df8f700c1cc4f740b74
treef77e915b7b9aff907dd5403d321ea0deb7e55bfa
parent913d0409f6b38b7652435f581f1e38768e4cb27a

Use Effect Schema to decode Codex structured outputs

- Replace manual JSON schema constants and parse helpers with `Schema.Struct` definitions - Generate JSON Schema from Effect Schema and decode output via `Schema.fromJsonString` - Map `SchemaError` to `TextGenerationError` for invalid Codex responses - Update `ProviderCommandReactor` test mock typing for `generateBranchName`

2 files changed, 49 insertions(+), 126 deletions(-)

apps/server/src/git/Layers/CodexTextGeneration.ts+41-115
...@@ -20,35 +20,6 @@ const CODEX_MODEL = "gpt-5.3-codex";...@@ -20,35 +20,6 @@ const CODEX_MODEL = "gpt-5.3-codex";
20const CODEX_REASONING_EFFORT = "low";20const CODEX_REASONING_EFFORT = "low";
21const CODEX_TIMEOUT_MS = 180_000;21const CODEX_TIMEOUT_MS = 180_000;
2222
23const COMMIT_OUTPUT_SCHEMA_JSON = {
24 type: "object",
25 properties: {
26 subject: { type: "string" },
27 body: { type: "string" },
28 },
29 required: ["subject", "body"],
30 additionalProperties: false,
31} as const;
32
33const PR_OUTPUT_SCHEMA_JSON = {
34 type: "object",
35 properties: {
36 title: { type: "string" },
37 body: { type: "string" },
38 },
39 required: ["title", "body"],
40 additionalProperties: false,
41} as const;
42
43const BRANCH_NAME_OUTPUT_SCHEMA_JSON = {
44 type: "object",
45 properties: {
46 branch: { type: "string" },
47 },
48 required: ["branch"],
49 additionalProperties: false,
50} as const;
51
52function normalizeCodexError(23function normalizeCodexError(
53 operation: string,24 operation: string,
54 error: unknown,25 error: unknown,
...@@ -85,44 +56,6 @@ function normalizeCodexError(...@@ -85,44 +56,6 @@ function normalizeCodexError(
85 });56 });
86}57}
8758
88function parseCommitOutput(raw: unknown): { subject: string; body: string } {
89 if (!raw || typeof raw !== "object") {
90 throw new Error("Codex returned a non-object commit message payload.");
91 }
92 const record = raw as Record<string, unknown>;
93 const subject = typeof record.subject === "string" ? record.subject.trim() : "";
94 const body = typeof record.body === "string" ? record.body : "";
95 if (subject.length === 0) {
96 throw new Error("Codex returned an empty commit subject.");
97 }
98 return { subject, body };
99}
100
101function parsePrOutput(raw: unknown): { title: string; body: string } {
102 if (!raw || typeof raw !== "object") {
103 throw new Error("Codex returned a non-object PR payload.");
104 }
105 const record = raw as Record<string, unknown>;
106 const title = typeof record.title === "string" ? record.title.trim() : "";
107 const body = typeof record.body === "string" ? record.body.trim() : "";
108 if (title.length === 0 || body.length === 0) {
109 throw new Error("Codex returned an invalid PR title/body payload.");
110 }
111 return { title, body };
112}
113
114function parseBranchNameOutput(raw: unknown): { branch: string } {
115 if (!raw || typeof raw !== "object") {
116 throw new Error("Codex returned a non-object branch payload.");
117 }
118 const record = raw as Record<string, unknown>;
119 const branch = typeof record.branch === "string" ? record.branch.trim() : "";
120 if (branch.length === 0) {
121 throw new Error("Codex returned an empty branch name payload.");
122 }
123 return { branch };
124}
125
126function limitSection(value: string, maxChars: number): string {59function limitSection(value: string, maxChars: number): string {
127 if (value.length <= maxChars) return value;60 if (value.length <= maxChars) return value;
128 const truncated = value.slice(0, maxChars);61 const truncated = value.slice(0, maxChars);
...@@ -276,20 +209,19 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -276,20 +209,19 @@ const makeCodexTextGeneration = Effect.gen(function* () {
276 continue;209 continue;
277 }210 }
278211
279 const persistedPath =212 const persistedPath = Option.isSome(serverConfig)
280 Option.isSome(serverConfig)213 ? resolveAttachmentRoutePath({
281 ? resolveAttachmentRoutePath({214 stateDir: serverConfig.value.stateDir,
282 stateDir: serverConfig.value.stateDir,215 dataUrl: attachment.dataUrl,
283 dataUrl: attachment.dataUrl,216 })
284 })217 : null;
285 : null;
286 const localPath = persistedPath ?? attachment.dataUrl;218 const localPath = persistedPath ?? attachment.dataUrl;
287 if (!path.isAbsolute(localPath)) {219 if (!path.isAbsolute(localPath)) {
288 continue;220 continue;
289 }221 }
290 const fileInfo = yield* fileSystem.stat(localPath).pipe(222 const fileInfo = yield* fileSystem
291 Effect.catch(() => Effect.succeed(null)),223 .stat(localPath)
292 );224 .pipe(Effect.catch(() => Effect.succeed(null)));
293 if (!fileInfo || fileInfo.type !== "File") {225 if (!fileInfo || fileInfo.type !== "File") {
294 continue;226 continue;
295 }227 }
...@@ -298,26 +230,24 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -298,26 +230,24 @@ const makeCodexTextGeneration = Effect.gen(function* () {
298 return imagePaths;230 return imagePaths;
299 });231 });
300232
301 const runCodexJson = <T>({233 const runCodexJson = <S extends Schema.Top>({
302 operation,234 operation,
303 cwd,235 cwd,
304 prompt,236 prompt,
305 outputSchemaJson,237 outputSchemaJson,
306 imagePaths = [],238 imagePaths = [],
307 parse,
308 }: {239 }: {
309 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";240 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
310 cwd: string;241 cwd: string;
311 prompt: string;242 prompt: string;
312 outputSchemaJson: object;243 outputSchemaJson: S;
313 imagePaths?: ReadonlyArray<string>;244 imagePaths?: ReadonlyArray<string>;
314 parse: (raw: unknown) => T;245 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
315 }): Effect.Effect<T, TextGenerationError> =>
316 Effect.gen(function* () {246 Effect.gen(function* () {
317 const schemaPath = yield* writeTempFile(247 const schemaPath = yield* writeTempFile(
318 operation,248 operation,
319 "codex-schema",249 "codex-schema",
320 JSON.stringify(outputSchemaJson),250 JSON.stringify(Schema.toJsonSchemaDocument(outputSchemaJson).schema),
321 );251 );
322 const outputPath = yield* writeTempFile(operation, "codex-output", "");252 const outputPath = yield* writeTempFile(operation, "codex-output", "");
323253
...@@ -406,7 +336,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -406,7 +336,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
406 ),336 ),
407 );337 );
408338
409 const rawOutput = yield* fileSystem.readFileString(outputPath).pipe(339 return yield* fileSystem.readFileString(outputPath).pipe(
410 Effect.mapError(340 Effect.mapError(
411 (cause) =>341 (cause) =>
412 new TextGenerationError({342 new TextGenerationError({
...@@ -415,30 +345,17 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -415,30 +345,17 @@ const makeCodexTextGeneration = Effect.gen(function* () {
415 cause,345 cause,
416 }),346 }),
417 ),347 ),
348 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))),
349 Effect.catchTag("SchemaError", (cause) =>
350 Effect.fail(
351 new TextGenerationError({
352 operation,
353 detail: "Codex returned invalid structured output.",
354 cause,
355 }),
356 ),
357 ),
418 );358 );
419 const trimmed = rawOutput.trim();
420 if (trimmed.length === 0) {
421 return yield* new TextGenerationError({
422 operation,
423 detail: "Codex returned an empty response.",
424 });
425 }
426
427 const parsedJson = yield* Effect.try({
428 try: () => JSON.parse(trimmed) as unknown,
429 catch: (cause) =>
430 new TextGenerationError({
431 operation,
432 detail: "Codex returned invalid JSON output.",
433 cause,
434 }),
435 });
436
437 return yield* Effect.try({
438 try: () => parse(parsedJson),
439 catch: (cause) =>
440 normalizeCodexError(operation, cause, "Codex returned invalid structured output"),
441 });
442 }).pipe(Effect.ensuring(cleanup));359 }).pipe(Effect.ensuring(cleanup));
443 });360 });
444361
...@@ -464,8 +381,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -464,8 +381,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
464 operation: "generateCommitMessage",381 operation: "generateCommitMessage",
465 cwd: input.cwd,382 cwd: input.cwd,
466 prompt,383 prompt,
467 outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON,384 outputSchemaJson: Schema.Struct({
468 parse: (raw) => parseCommitOutput(raw),385 subject: Schema.String,
386 body: Schema.String,
387 }),
469 }).pipe(388 }).pipe(
470 Effect.map(389 Effect.map(
471 (generated) =>390 (generated) =>
...@@ -504,8 +423,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -504,8 +423,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
504 operation: "generatePrContent",423 operation: "generatePrContent",
505 cwd: input.cwd,424 cwd: input.cwd,
506 prompt,425 prompt,
507 outputSchemaJson: PR_OUTPUT_SCHEMA_JSON,426 outputSchemaJson: Schema.Struct({
508 parse: (raw) => parsePrOutput(raw),427 title: Schema.String,
428 body: Schema.String,
429 }),
509 }).pipe(430 }).pipe(
510 Effect.map(431 Effect.map(
511 (generated) =>432 (generated) =>
...@@ -519,9 +440,13 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -519,9 +440,13 @@ const makeCodexTextGeneration = Effect.gen(function* () {
519440
520 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {441 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
521 return Effect.gen(function* () {442 return Effect.gen(function* () {
522 const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments);443 const imagePaths = yield* materializeImageAttachments(
444 "generateBranchName",
445 input.attachments,
446 );
523 const attachmentLines = (input.attachments ?? []).map(447 const attachmentLines = (input.attachments ?? []).map(
524 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,448 (attachment) =>
449 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
525 );450 );
526451
527 const promptSections = [452 const promptSections = [
...@@ -549,9 +474,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -549,9 +474,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
549 operation: "generateBranchName",474 operation: "generateBranchName",
550 cwd: input.cwd,475 cwd: input.cwd,
551 prompt,476 prompt,
552 outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON,477 outputSchemaJson: Schema.Struct({
478 branch: Schema.String,
479 }),
553 imagePaths,480 imagePaths,
554 parse: (raw) => parseBranchNameOutput(raw),
555 });481 });
556482
557 return {483 return {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+8-11
...@@ -122,7 +122,7 @@ describe("ProviderCommandReactor", () => {...@@ -122,7 +122,7 @@ describe("ProviderCommandReactor", () => {
122 branch: "t3code/generated-name",122 branch: "t3code/generated-name",
123 }),123 }),
124 );124 );
125 const generateBranchName = vi.fn((_: unknown) =>125 const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>(() =>
126 Effect.succeed({126 Effect.succeed({
127 branch: "generated-name",127 branch: "generated-name",
128 }),128 }),
...@@ -152,18 +152,13 @@ describe("ProviderCommandReactor", () => {...@@ -152,18 +152,13 @@ describe("ProviderCommandReactor", () => {
152 Layer.provideMerge(Layer.succeed(ProviderService, service)),152 Layer.provideMerge(Layer.succeed(ProviderService, service)),
153 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),153 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
154 Layer.provideMerge(154 Layer.provideMerge(
155 Layer.succeed(155 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
156 TextGeneration,
157 { generateBranchName } as unknown as TextGenerationShape,
158 ),
159 ),156 ),
160 );157 );
161 const layer =158 const layer =
162 input?.stateDir !== undefined159 input?.stateDir !== undefined
163 ? liveLayer.pipe(160 ? liveLayer.pipe(
164 Layer.provideMerge(161 Layer.provideMerge(Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir))),
165 Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir)),
166 ),
167 )162 )
168 : liveLayer;163 : liveLayer;
169 runtime = ManagedRuntime.make(layer);164 runtime = ManagedRuntime.make(layer);
...@@ -311,7 +306,9 @@ describe("ProviderCommandReactor", () => {...@@ -311,7 +306,9 @@ describe("ProviderCommandReactor", () => {
311306
312 await waitFor(() => {307 await waitFor(() => {
313 const readModel = Effect.runSync(harness.engine.getReadModel());308 const readModel = Effect.runSync(harness.engine.getReadModel());
314 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));309 const thread = readModel.threads.find(
310 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
311 );
315 return thread?.branch === "t3code/generated-name";312 return thread?.branch === "t3code/generated-name";
316 });313 });
317 });314 });
...@@ -628,8 +625,8 @@ describe("ProviderCommandReactor", () => {...@@ -628,8 +625,8 @@ describe("ProviderCommandReactor", () => {
628 await waitFor(() => harness.startSession.mock.calls.length === 1);625 await waitFor(() => harness.startSession.mock.calls.length === 1);
629 await waitFor(() => harness.sendTurn.mock.calls.length === 1);626 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
630627
631 harness.startSession.mockImplementationOnce((_: unknown, __: unknown) =>628 harness.startSession.mockImplementationOnce(
632 Effect.fail(new Error("simulated restart failure")) as never,629 (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never,
633 );630 );
634631
635 await Effect.runPromise(632 await Effect.runPromise(