diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 376e1ed5bb0632b49e9fdfc7368a34d1427aab7a..e1779f52212e6ba7357c752f6156b05742889a25 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -20,35 +20,6 @@ const CODEX_MODEL = "gpt-5.3-codex"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -const COMMIT_OUTPUT_SCHEMA_JSON = { - type: "object", - properties: { - subject: { type: "string" }, - body: { type: "string" }, - }, - required: ["subject", "body"], - additionalProperties: false, -} as const; - -const PR_OUTPUT_SCHEMA_JSON = { - type: "object", - properties: { - title: { type: "string" }, - body: { type: "string" }, - }, - required: ["title", "body"], - additionalProperties: false, -} as const; - -const BRANCH_NAME_OUTPUT_SCHEMA_JSON = { - type: "object", - properties: { - branch: { type: "string" }, - }, - required: ["branch"], - additionalProperties: false, -} as const; - function normalizeCodexError( operation: string, error: unknown, @@ -85,44 +56,6 @@ function normalizeCodexError( }); } -function parseCommitOutput(raw: unknown): { subject: string; body: string } { - if (!raw || typeof raw !== "object") { - throw new Error("Codex returned a non-object commit message payload."); - } - const record = raw as Record; - const subject = typeof record.subject === "string" ? record.subject.trim() : ""; - const body = typeof record.body === "string" ? record.body : ""; - if (subject.length === 0) { - throw new Error("Codex returned an empty commit subject."); - } - return { subject, body }; -} - -function parsePrOutput(raw: unknown): { title: string; body: string } { - if (!raw || typeof raw !== "object") { - throw new Error("Codex returned a non-object PR payload."); - } - const record = raw as Record; - const title = typeof record.title === "string" ? record.title.trim() : ""; - const body = typeof record.body === "string" ? record.body.trim() : ""; - if (title.length === 0 || body.length === 0) { - throw new Error("Codex returned an invalid PR title/body payload."); - } - return { title, body }; -} - -function parseBranchNameOutput(raw: unknown): { branch: string } { - if (!raw || typeof raw !== "object") { - throw new Error("Codex returned a non-object branch payload."); - } - const record = raw as Record; - const branch = typeof record.branch === "string" ? record.branch.trim() : ""; - if (branch.length === 0) { - throw new Error("Codex returned an empty branch name payload."); - } - return { branch }; -} - function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); @@ -276,20 +209,19 @@ const makeCodexTextGeneration = Effect.gen(function* () { continue; } - const persistedPath = - Option.isSome(serverConfig) - ? resolveAttachmentRoutePath({ - stateDir: serverConfig.value.stateDir, - dataUrl: attachment.dataUrl, - }) - : null; + const persistedPath = Option.isSome(serverConfig) + ? resolveAttachmentRoutePath({ + stateDir: serverConfig.value.stateDir, + dataUrl: attachment.dataUrl, + }) + : null; const localPath = persistedPath ?? attachment.dataUrl; if (!path.isAbsolute(localPath)) { continue; } - const fileInfo = yield* fileSystem.stat(localPath).pipe( - Effect.catch(() => Effect.succeed(null)), - ); + const fileInfo = yield* fileSystem + .stat(localPath) + .pipe(Effect.catch(() => Effect.succeed(null))); if (!fileInfo || fileInfo.type !== "File") { continue; } @@ -298,26 +230,24 @@ const makeCodexTextGeneration = Effect.gen(function* () { return imagePaths; }); - const runCodexJson = ({ + const runCodexJson = ({ operation, cwd, prompt, outputSchemaJson, imagePaths = [], - parse, }: { operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; prompt: string; - outputSchemaJson: object; + outputSchemaJson: S; imagePaths?: ReadonlyArray; - parse: (raw: unknown) => T; - }): Effect.Effect => + }): Effect.Effect => Effect.gen(function* () { const schemaPath = yield* writeTempFile( operation, "codex-schema", - JSON.stringify(outputSchemaJson), + JSON.stringify(Schema.toJsonSchemaDocument(outputSchemaJson).schema), ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); @@ -406,7 +336,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { ), ); - const rawOutput = yield* fileSystem.readFileString(outputPath).pipe( + return yield* fileSystem.readFileString(outputPath).pipe( Effect.mapError( (cause) => new TextGenerationError({ @@ -415,30 +345,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { cause, }), ), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Codex returned invalid structured output.", + cause, + }), + ), + ), ); - const trimmed = rawOutput.trim(); - if (trimmed.length === 0) { - return yield* new TextGenerationError({ - operation, - detail: "Codex returned an empty response.", - }); - } - - const parsedJson = yield* Effect.try({ - try: () => JSON.parse(trimmed) as unknown, - catch: (cause) => - new TextGenerationError({ - operation, - detail: "Codex returned invalid JSON output.", - cause, - }), - }); - - return yield* Effect.try({ - try: () => parse(parsedJson), - catch: (cause) => - normalizeCodexError(operation, cause, "Codex returned invalid structured output"), - }); }).pipe(Effect.ensuring(cleanup)); }); @@ -464,8 +381,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, - parse: (raw) => parseCommitOutput(raw), + outputSchemaJson: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }), }).pipe( Effect.map( (generated) => @@ -504,8 +423,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { operation: "generatePrContent", cwd: input.cwd, prompt, - outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, - parse: (raw) => parsePrOutput(raw), + outputSchemaJson: Schema.Struct({ + title: Schema.String, + body: Schema.String, + }), }).pipe( Effect.map( (generated) => @@ -519,9 +440,13 @@ const makeCodexTextGeneration = Effect.gen(function* () { const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { return Effect.gen(function* () { - const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments); + const imagePaths = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); const attachmentLines = (input.attachments ?? []).map( - (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); const promptSections = [ @@ -549,9 +474,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { operation: "generateBranchName", cwd: input.cwd, prompt, - outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON, + outputSchemaJson: Schema.Struct({ + branch: Schema.String, + }), imagePaths, - parse: (raw) => parseBranchNameOutput(raw), }); return { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0a76ab8655999a3c626f135dd366e3c487ab802e..8b608aa51e2a8191f29667bd4bb9c4342718b85b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -122,7 +122,7 @@ describe("ProviderCommandReactor", () => { branch: "t3code/generated-name", }), ); - const generateBranchName = vi.fn((_: unknown) => + const generateBranchName = vi.fn(() => Effect.succeed({ branch: "generated-name", }), @@ -152,18 +152,13 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed( - TextGeneration, - { generateBranchName } as unknown as TextGenerationShape, - ), + Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), ); const layer = input?.stateDir !== undefined ? liveLayer.pipe( - Layer.provideMerge( - Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir)), - ), + Layer.provideMerge(Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir))), ) : liveLayer; runtime = ManagedRuntime.make(layer); @@ -311,7 +306,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => { const readModel = Effect.runSync(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); return thread?.branch === "t3code/generated-name"; }); }); @@ -628,8 +625,8 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - harness.startSession.mockImplementationOnce((_: unknown, __: unknown) => - Effect.fail(new Error("simulated restart failure")) as never, + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, ); await Effect.runPromise(