Skip to content

Commit 2ae64f4

Browse files
authored
refactor(core): migrate MessageV2.Format to Effect Schema (anomalyco#23744)
1 parent 7933657 commit 2ae64f4

4 files changed

Lines changed: 103 additions & 28 deletions

File tree

packages/opencode/specs/effect/schema.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,80 @@ schema module with a clear domain.
186186
Major cluster. Message + event types flow through the SSE API and every SDK
187187
output, so byte-identical SDK surface is critical.
188188

189+
Suggested order for this cluster, starting from the leaves that `session.ts`
190+
and the SSE/event surface depend on:
191+
192+
1. `src/session/schema.ts` ✅ already migrated
193+
2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs
194+
3. `src/lsp/*` schema leaves needed by `LSP.Range`
195+
4. `src/snapshot/*` leaves used by `Snapshot.FileDiff`
196+
5. `src/session/message-v2.ts`
197+
6. `src/session/message.ts`
198+
7. `src/session/prompt.ts`
199+
8. `src/session/revert.ts`
200+
9. `src/session/summary.ts`
201+
10. `src/session/status.ts`
202+
11. `src/session/todo.ts`
203+
12. `src/session/session.ts`
204+
13. `src/session/compaction.ts`
205+
206+
Dependency sketch:
207+
208+
```text
209+
session.ts
210+
|- project/schema.ts
211+
|- control-plane/schema.ts
212+
|- permission/schema.ts
213+
|- snapshot/*
214+
|- message-v2.ts
215+
| |- provider/schema.ts
216+
| |- lsp/*
217+
| |- snapshot/*
218+
| |- sync/index.ts
219+
| `- bus/bus-event.ts
220+
|- sync/index.ts
221+
|- bus/bus-event.ts
222+
`- util/update-schema.ts
223+
```
224+
225+
Working rule for this cluster:
226+
227+
- migrate reusable leaf schemas and nested payload objects first
228+
- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as
229+
named Schema values
230+
- leave zod-only event/update helpers in place temporarily when converting
231+
them would force unrelated churn across sync/bus boundaries
232+
233+
`message-v2.ts` first-pass outline:
234+
235+
1. Schema-backed imports already available
236+
- `SessionID`, `MessageID`, `PartID`
237+
- `ProviderID`, `ModelID`
238+
2. Local leaf objects to extract and migrate first
239+
- output format payloads
240+
- common part bases like `PartBase`
241+
- timestamp/range helper objects like `time.start/end`
242+
- file/source helper objects
243+
- token/cost/model helper objects
244+
3. Part variants built from those leaves
245+
- `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart`
246+
- `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart`
247+
- retry/step/tool related parts
248+
4. Higher-level unions and DTOs
249+
- `FilePartSource`
250+
- part unions
251+
- message unions and assistant/user payloads
252+
5. Errors and event payloads last
253+
- `NamedError.create(...)` shapes can stay temporarily if converting them to
254+
`Schema.TaggedErrorClass` would force unrelated churn
255+
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using
256+
derived `.zod` until the sync/bus layers are migrated
257+
258+
Possible later tightening after the Schema-first migration is stable:
259+
260+
- promote repeated opaque strings and timestamp numbers into branded/newtype
261+
leaf schemas where that adds domain value without changing the wire format
262+
189263
- [ ] `src/session/compaction.ts`
190264
- [ ] `src/session/message-v2.ts`
191265
- [ ] `src/session/message.ts`

packages/opencode/src/session/message-v2.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import { isMedia } from "@/util/media"
1515
import type { SystemError } from "bun"
1616
import type { Provider } from "@/provider"
1717
import { ModelID, ProviderID } from "@/provider/schema"
18-
import { Effect } from "effect"
18+
import { Effect, Schema } from "effect"
19+
import { zod } from "@/util/effect-zod"
1920
import { EffectLogger } from "@/effect"
2021

2122
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
@@ -61,28 +62,28 @@ export const ContextOverflowError = NamedError.create(
6162
z.object({ message: z.string(), responseBody: z.string().optional() }),
6263
)
6364

64-
export const OutputFormatText = z
65-
.object({
66-
type: z.literal("text"),
67-
})
68-
.meta({
69-
ref: "OutputFormatText",
70-
})
65+
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
66+
type: Schema.Literal("text"),
67+
}) {
68+
static readonly zod = zod(this)
69+
}
7170

72-
export const OutputFormatJsonSchema = z
73-
.object({
74-
type: z.literal("json_schema"),
75-
schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
76-
retryCount: z.number().int().min(0).default(2),
77-
})
78-
.meta({
79-
ref: "OutputFormatJsonSchema",
80-
})
71+
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
72+
type: Schema.Literal("json_schema"),
73+
schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
74+
retryCount: Schema.Number.check(Schema.isInt())
75+
.check(Schema.isGreaterThanOrEqualTo(0))
76+
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
77+
}) {
78+
static readonly zod = zod(this)
79+
}
8180

82-
export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({
83-
ref: "OutputFormat",
81+
const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
82+
discriminator: "type",
83+
identifier: "OutputFormat",
8484
})
85-
export type OutputFormat = z.infer<typeof Format>
85+
export const Format = Object.assign(_Format, { zod: zod(_Format) })
86+
export type OutputFormat = Schema.Schema.Type<typeof _Format>
8687

8788
const PartBase = z.object({
8889
id: PartID.zod,
@@ -360,7 +361,7 @@ export const User = Base.extend({
360361
time: z.object({
361362
created: z.number(),
362363
}),
363-
format: Format.optional(),
364+
format: Format.zod.optional(),
364365
summary: z
365366
.object({
366367
title: z.string().optional(),

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1716,7 +1716,7 @@ export const PromptInput = z.object({
17161716
.record(z.string(), z.boolean())
17171717
.optional()
17181718
.describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"),
1719-
format: MessageV2.Format.optional(),
1719+
format: MessageV2.Format.zod.optional(),
17201720
system: z.string().optional(),
17211721
variant: z.string().optional(),
17221722
parts: z.array(

packages/opencode/test/session/structured-output.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ import { SessionID, MessageID } from "../../src/session/schema"
55

66
describe("structured-output.OutputFormat", () => {
77
test("parses text format", () => {
8-
const result = MessageV2.Format.safeParse({ type: "text" })
8+
const result = MessageV2.Format.zod.safeParse({ type: "text" })
99
expect(result.success).toBe(true)
1010
if (result.success) {
1111
expect(result.data.type).toBe("text")
1212
}
1313
})
1414

1515
test("parses json_schema format with defaults", () => {
16-
const result = MessageV2.Format.safeParse({
16+
const result = MessageV2.Format.zod.safeParse({
1717
type: "json_schema",
1818
schema: { type: "object", properties: { name: { type: "string" } } },
1919
})
@@ -27,7 +27,7 @@ describe("structured-output.OutputFormat", () => {
2727
})
2828

2929
test("parses json_schema format with custom retryCount", () => {
30-
const result = MessageV2.Format.safeParse({
30+
const result = MessageV2.Format.zod.safeParse({
3131
type: "json_schema",
3232
schema: { type: "object" },
3333
retryCount: 5,
@@ -39,17 +39,17 @@ describe("structured-output.OutputFormat", () => {
3939
})
4040

4141
test("rejects invalid type", () => {
42-
const result = MessageV2.Format.safeParse({ type: "invalid" })
42+
const result = MessageV2.Format.zod.safeParse({ type: "invalid" })
4343
expect(result.success).toBe(false)
4444
})
4545

4646
test("rejects json_schema without schema", () => {
47-
const result = MessageV2.Format.safeParse({ type: "json_schema" })
47+
const result = MessageV2.Format.zod.safeParse({ type: "json_schema" })
4848
expect(result.success).toBe(false)
4949
})
5050

5151
test("rejects negative retryCount", () => {
52-
const result = MessageV2.Format.safeParse({
52+
const result = MessageV2.Format.zod.safeParse({
5353
type: "json_schema",
5454
schema: { type: "object" },
5555
retryCount: -1,

0 commit comments

Comments
 (0)