From d18c5b1fb2471fc3c46a533f5dd677a2a18d2a0b Mon Sep 17 00:00:00 2001 From: Padraig Myers Date: Thu, 20 Aug 2026 20:39:10 +0000 Subject: [PATCH] fix: make OutputFormat schemas encodable from persisted plain objects Fixes #26929 OutputFormatText and OutputFormatJsonSchema were declared with Schema.Class, which only encodes genuine class instances. When a format object is persisted onto a user message and read back from MessageTable, the row's data column holds a plain object (message-v2.ts info() spreads row.data). The GET /session/:id/message endpoint validates its response against Schema.Array(SessionV1.WithParts), so re-encoding the stored format through Schema.Class fails and the entire response returns 400. Converting both schemas to Schema.Struct makes encode accept plain objects while preserving all decode validation (negative retryCount is still rejected, json_schema without schema is still rejected). Neither schema is ever constructed with new() or checked with instanceof, so dropping the class identity is safe. Added encode regression tests that fail without the fix (3 of 4 new tests fail on Schema.Class) and pass with Schema.Struct. --- .../test/session/structured-output.test.ts | 62 +++++++++++++++++++ packages/schema/src/v1/session.ts | 8 +-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts index f71b535a9d5b..77c72ea3e9bc 100644 --- a/packages/opencode/test/session/structured-output.test.ts +++ b/packages/opencode/test/session/structured-output.test.ts @@ -64,6 +64,68 @@ describe("structured-output.OutputFormat", () => { }) }) +// Regression tests for https://github.com/anomalyco/opencode/issues/26929 +// +// OutputFormatText and OutputFormatJsonSchema were declared with Schema.Class, +// which only encodes genuine class instances. When a format object is +// persisted onto a user message and read back out of MessageTable, the row's +// data column holds a plain object (message-v2.ts info() spreads row.data). +// The GET /session/:id/message endpoint validates its response against +// Schema.Array(SessionV1.WithParts), so re-encoding the stored format through +// Schema.Class failed with a 400 — one bad message rejected the entire array. +// +// These tests verify that plain objects round-trip through encode, which is +// the condition the HTTP response path requires. +describe("structured-output.OutputFormat.encode (regression for #26929)", () => { + const encodeFormat = Schema.encodeUnknownExit(SessionV1.Format) + + test("encodes text format from a plain object", () => { + const result = encodeFormat({ type: "text" }) + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result)) { + expect(result.value).toEqual({ type: "text" }) + } + }) + + test("encodes json_schema format from a plain object with retryCount", () => { + const result = encodeFormat({ + type: "json_schema", + schema: { type: "object", properties: { name: { type: "string" } } }, + retryCount: 2, + }) + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result)) { + const value = result.value as any + expect(value.type).toBe("json_schema") + expect(value.retryCount).toBe(2) + } + }) + + test("encodes json_schema format from a plain object without retryCount", () => { + // Messages persisted without an explicit retryCount should still encode. + // The decoding default fills in retryCount on decode, but encode must + // tolerate its absence (the DB row may not have it). + const result = encodeFormat({ + type: "json_schema", + schema: { type: "object" }, + }) + expect(Exit.isSuccess(result)).toBe(true) + }) + + test("round-trips json_schema format through decode then encode", () => { + const original = { + type: "json_schema" as const, + schema: { type: "object", properties: { markdown_text: { type: "string" } } }, + } + const decoded = decodeFormat(original) + expect(Exit.isSuccess(decoded)).toBe(true) + if (Exit.isSuccess(decoded)) { + const encoded = encodeFormat(decoded.value) + expect(Exit.isSuccess(encoded)).toBe(true) + } + }) +}) + describe("structured-output.StructuredOutputError", () => { test("creates error with message and retries", () => { const error = new SessionV1.StructuredOutputError({ diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 75e9282f117c..5a5d25d8e95b 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -62,15 +62,15 @@ export const ContentFilterError = namedError("ContentFilterError", { message: Schema.String, }) -export class OutputFormatText extends Schema.Class("OutputFormatText")({ +export const OutputFormatText = Schema.Struct({ type: Schema.Literal("text"), -}) {} +}).annotate({ identifier: "OutputFormatText" }) -export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ +export const OutputFormatJsonSchema = Schema.Struct({ type: Schema.Literal("json_schema"), schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) {} +}).annotate({ identifier: "OutputFormatJsonSchema" }) export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ discriminator: "type",