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";
2020const CODEX_REASONING_EFFORT = "low";
2121const 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
5223function normalizeCodexError(
5324 operation: string,
5425 error: unknown,
......@@ -85,44 +56,6 @@ function normalizeCodexError(
8556 });
8657}
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
12659function limitSection(value: string, maxChars: number): string {
12760 if (value.length <= maxChars) return value;
12861 const truncated = value.slice(0, maxChars);
......@@ -276,20 +209,19 @@ const makeCodexTextGeneration = Effect.gen(function* () {
276209 continue;
277210 }
278211
279 const persistedPath =
280 Option.isSome(serverConfig)
281 ? resolveAttachmentRoutePath({
282 stateDir: serverConfig.value.stateDir,
283 dataUrl: attachment.dataUrl,
284 })
285 : null;
212 const persistedPath = Option.isSome(serverConfig)
213 ? resolveAttachmentRoutePath({
214 stateDir: serverConfig.value.stateDir,
215 dataUrl: attachment.dataUrl,
216 })
217 : null;
286218 const localPath = persistedPath ?? attachment.dataUrl;
287219 if (!path.isAbsolute(localPath)) {
288220 continue;
289221 }
290 const fileInfo = yield* fileSystem.stat(localPath).pipe(
291 Effect.catch(() => Effect.succeed(null)),
292 );
222 const fileInfo = yield* fileSystem
223 .stat(localPath)
224 .pipe(Effect.catch(() => Effect.succeed(null)));
293225 if (!fileInfo || fileInfo.type !== "File") {
294226 continue;
295227 }
......@@ -298,26 +230,24 @@ const makeCodexTextGeneration = Effect.gen(function* () {
298230 return imagePaths;
299231 });
300232
301 const runCodexJson = <T>({
233 const runCodexJson = <S extends Schema.Top>({
302234 operation,
303235 cwd,
304236 prompt,
305237 outputSchemaJson,
306238 imagePaths = [],
307 parse,
308239 }: {
309240 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
310241 cwd: string;
311242 prompt: string;
312 outputSchemaJson: object;
243 outputSchemaJson: S;
313244 imagePaths?: ReadonlyArray<string>;
314 parse: (raw: unknown) => T;
315 }): Effect.Effect<T, TextGenerationError> =>
245 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
316246 Effect.gen(function* () {
317247 const schemaPath = yield* writeTempFile(
318248 operation,
319249 "codex-schema",
320 JSON.stringify(outputSchemaJson),
250 JSON.stringify(Schema.toJsonSchemaDocument(outputSchemaJson).schema),
321251 );
322252 const outputPath = yield* writeTempFile(operation, "codex-output", "");
323253
......@@ -406,7 +336,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
406336 ),
407337 );
408338
409 const rawOutput = yield* fileSystem.readFileString(outputPath).pipe(
339 return yield* fileSystem.readFileString(outputPath).pipe(
410340 Effect.mapError(
411341 (cause) =>
412342 new TextGenerationError({
......@@ -415,30 +345,17 @@ const makeCodexTextGeneration = Effect.gen(function* () {
415345 cause,
416346 }),
417347 ),
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 ),
418358 );
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 });
442359 }).pipe(Effect.ensuring(cleanup));
443360 });
444361
......@@ -464,8 +381,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
464381 operation: "generateCommitMessage",
465382 cwd: input.cwd,
466383 prompt,
467 outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON,
468 parse: (raw) => parseCommitOutput(raw),
384 outputSchemaJson: Schema.Struct({
385 subject: Schema.String,
386 body: Schema.String,
387 }),
469388 }).pipe(
470389 Effect.map(
471390 (generated) =>
......@@ -504,8 +423,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
504423 operation: "generatePrContent",
505424 cwd: input.cwd,
506425 prompt,
507 outputSchemaJson: PR_OUTPUT_SCHEMA_JSON,
508 parse: (raw) => parsePrOutput(raw),
426 outputSchemaJson: Schema.Struct({
427 title: Schema.String,
428 body: Schema.String,
429 }),
509430 }).pipe(
510431 Effect.map(
511432 (generated) =>
......@@ -519,9 +440,13 @@ const makeCodexTextGeneration = Effect.gen(function* () {
519440
520441 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
521442 return Effect.gen(function* () {
522 const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments);
443 const imagePaths = yield* materializeImageAttachments(
444 "generateBranchName",
445 input.attachments,
446 );
523447 const attachmentLines = (input.attachments ?? []).map(
524 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
448 (attachment) =>
449 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
525450 );
526451
527452 const promptSections = [
......@@ -549,9 +474,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
549474 operation: "generateBranchName",
550475 cwd: input.cwd,
551476 prompt,
552 outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON,
477 outputSchemaJson: Schema.Struct({
478 branch: Schema.String,
479 }),
553480 imagePaths,
554 parse: (raw) => parseBranchNameOutput(raw),
555481 });
556482
557483 return {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+8-11
......@@ -122,7 +122,7 @@ describe("ProviderCommandReactor", () => {
122122 branch: "t3code/generated-name",
123123 }),
124124 );
125 const generateBranchName = vi.fn((_: unknown) =>
125 const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>(() =>
126126 Effect.succeed({
127127 branch: "generated-name",
128128 }),
......@@ -152,18 +152,13 @@ describe("ProviderCommandReactor", () => {
152152 Layer.provideMerge(Layer.succeed(ProviderService, service)),
153153 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
154154 Layer.provideMerge(
155 Layer.succeed(
156 TextGeneration,
157 { generateBranchName } as unknown as TextGenerationShape,
158 ),
155 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
159156 ),
160157 );
161158 const layer =
162159 input?.stateDir !== undefined
163160 ? liveLayer.pipe(
164 Layer.provideMerge(
165 Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir)),
166 ),
161 Layer.provideMerge(Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir))),
167162 )
168163 : liveLayer;
169164 runtime = ManagedRuntime.make(layer);
......@@ -311,7 +306,9 @@ describe("ProviderCommandReactor", () => {
311306
312307 await waitFor(() => {
313308 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 );
315312 return thread?.branch === "t3code/generated-name";
316313 });
317314 });
......@@ -628,8 +625,8 @@ describe("ProviderCommandReactor", () => {
628625 await waitFor(() => harness.startSession.mock.calls.length === 1);
629626 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
630627
631 harness.startSession.mockImplementationOnce((_: unknown, __: unknown) =>
632 Effect.fail(new Error("simulated restart failure")) as never,
628 harness.startSession.mockImplementationOnce(
629 (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never,
633630 );
634631
635632 await Effect.runPromise(