Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/opencode/specs/effect/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,80 @@ schema module with a clear domain.
Major cluster. Message + event types flow through the SSE API and every SDK
output, so byte-identical SDK surface is critical.

Suggested order for this cluster, starting from the leaves that `session.ts`
and the SSE/event surface depend on:

1. `src/session/schema.ts` ✅ already migrated
2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs
3. `src/lsp/*` schema leaves needed by `LSP.Range`
4. `src/snapshot/*` leaves used by `Snapshot.FileDiff`
5. `src/session/message-v2.ts`
6. `src/session/message.ts`
7. `src/session/prompt.ts`
8. `src/session/revert.ts`
9. `src/session/summary.ts`
10. `src/session/status.ts`
11. `src/session/todo.ts`
12. `src/session/session.ts`
13. `src/session/compaction.ts`

Dependency sketch:

```text
session.ts
|- project/schema.ts
|- control-plane/schema.ts
|- permission/schema.ts
|- snapshot/*
|- message-v2.ts
| |- provider/schema.ts
| |- lsp/*
| |- snapshot/*
| |- sync/index.ts
| `- bus/bus-event.ts
|- sync/index.ts
|- bus/bus-event.ts
`- util/update-schema.ts
```

Working rule for this cluster:

- migrate reusable leaf schemas and nested payload objects first
- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as
named Schema values
- leave zod-only event/update helpers in place temporarily when converting
them would force unrelated churn across sync/bus boundaries

`message-v2.ts` first-pass outline:

1. Schema-backed imports already available
- `SessionID`, `MessageID`, `PartID`
- `ProviderID`, `ModelID`
2. Local leaf objects to extract and migrate first
- output format payloads
- common part bases like `PartBase`
- timestamp/range helper objects like `time.start/end`
- file/source helper objects
- token/cost/model helper objects
3. Part variants built from those leaves
- `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart`
- `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart`
- retry/step/tool related parts
4. Higher-level unions and DTOs
- `FilePartSource`
- part unions
- message unions and assistant/user payloads
5. Errors and event payloads last
- `NamedError.create(...)` shapes can stay temporarily if converting them to
`Schema.TaggedErrorClass` would force unrelated churn
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using
derived `.zod` until the sync/bus layers are migrated

Possible later tightening after the Schema-first migration is stable:

- promote repeated opaque strings and timestamp numbers into branded/newtype
leaf schemas where that adds domain value without changing the wire format

- [ ] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts`
- [ ] `src/session/message.ts`
Expand Down
43 changes: 22 additions & 21 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { isMedia } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { EffectLogger } from "@/effect"

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

export const OutputFormatText = z
.object({
type: z.literal("text"),
})
.meta({
ref: "OutputFormatText",
})
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
type: Schema.Literal("text"),
}) {
static readonly zod = zod(this)
}

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

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

const PartBase = z.object({
id: PartID.zod,
Expand Down Expand Up @@ -360,7 +361,7 @@ export const User = Base.extend({
time: z.object({
created: z.number(),
}),
format: Format.optional(),
format: Format.zod.optional(),
summary: z
.object({
title: z.string().optional(),
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1716,7 +1716,7 @@ export const PromptInput = z.object({
.record(z.string(), z.boolean())
.optional()
.describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"),
format: MessageV2.Format.optional(),
format: MessageV2.Format.zod.optional(),
system: z.string().optional(),
variant: z.string().optional(),
parts: z.array(
Expand Down
12 changes: 6 additions & 6 deletions packages/opencode/test/session/structured-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { SessionID, MessageID } from "../../src/session/schema"

describe("structured-output.OutputFormat", () => {
test("parses text format", () => {
const result = MessageV2.Format.safeParse({ type: "text" })
const result = MessageV2.Format.zod.safeParse({ type: "text" })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.type).toBe("text")
}
})

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

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

test("rejects invalid type", () => {
const result = MessageV2.Format.safeParse({ type: "invalid" })
const result = MessageV2.Format.zod.safeParse({ type: "invalid" })
expect(result.success).toBe(false)
})

test("rejects json_schema without schema", () => {
const result = MessageV2.Format.safeParse({ type: "json_schema" })
const result = MessageV2.Format.zod.safeParse({ type: "json_schema" })
expect(result.success).toBe(false)
})

test("rejects negative retryCount", () => {
const result = MessageV2.Format.safeParse({
const result = MessageV2.Format.zod.safeParse({
type: "json_schema",
schema: { type: "object" },
retryCount: -1,
Expand Down
Loading