소스 검색

refactor(session): migrate session domain to Effect Schema (#24005)

Kit Langton 3 달 전
부모
커밋
0517ab4695

+ 12 - 9
packages/opencode/specs/effect/schema.md

@@ -159,6 +159,7 @@ Schema at source.
 These are the highest-priority next targets. Each is a small, self-contained
 schema module with a clear domain.
 
+- [x] `src/account/schema.ts`
 - [x] `src/control-plane/schema.ts`
 - [x] `src/permission/schema.ts`
 - [x] `src/project/schema.ts`
@@ -166,8 +167,10 @@ schema module with a clear domain.
 - [x] `src/pty/schema.ts`
 - [x] `src/question/schema.ts`
 - [x] `src/session/schema.ts`
+- [x] `src/storage/schema.ts`
 - [x] `src/sync/schema.ts`
 - [x] `src/tool/schema.ts`
+- [x] `src/util/schema.ts`
 
 ### Session domain
 
@@ -248,15 +251,15 @@ 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`
-- [ ] `src/session/prompt.ts`
-- [ ] `src/session/revert.ts`
-- [ ] `src/session/session.ts`
-- [ ] `src/session/status.ts`
-- [ ] `src/session/summary.ts`
-- [ ] `src/session/todo.ts`
+- [x] `src/session/compaction.ts`
+- [x] `src/session/message-v2.ts`
+- [x] `src/session/message.ts`
+- [x] `src/session/prompt.ts`
+- [x] `src/session/revert.ts`
+- [x] `src/session/session.ts`
+- [x] `src/session/status.ts`
+- [x] `src/session/summary.ts`
+- [x] `src/session/todo.ts`
 
 ### Provider domain
 

+ 2 - 2
packages/opencode/src/acp/agent.ts

@@ -372,7 +372,7 @@ export class Agent implements ACPAgent {
               }
 
               if (part.tool === "todowrite") {
-                const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
+                const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
                 if (parsedTodos.success) {
                   await this.connection
                     .sessionUpdate({
@@ -901,7 +901,7 @@ export class Agent implements ACPAgent {
             }
 
             if (part.tool === "todowrite") {
-              const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
+              const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
               if (parsedTodos.success) {
                 await this.connection
                   .sessionUpdate({

+ 3 - 2
packages/opencode/src/cli/cmd/import.ts

@@ -11,6 +11,7 @@ import { ShareNext } from "../../share"
 import { EOL } from "os"
 import { Filesystem } from "../../util"
 import { AppRuntime } from "@/effect/app-runtime"
+import { Schema } from "effect"
 
 /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
 export type ShareData =
@@ -154,10 +155,10 @@ export const ImportCommand = cmd({
         return
       }
 
-      const info = Session.Info.parse({
+      const info = Schema.decodeUnknownSync(Session.Info)({
         ...exportData.info,
         projectID: Instance.project.id,
-      })
+      }) as Session.Info
       const row = Session.toRow(info)
       Database.use((db) =>
         db

+ 1 - 1
packages/opencode/src/server/routes/instance/experimental.ts

@@ -335,7 +335,7 @@ export const ExperimentalRoutes = lazy(() =>
             description: "List of sessions",
             content: {
               "application/json": {
-                schema: resolver(Session.GlobalInfo.array()),
+                schema: resolver(Session.GlobalInfo.zod.array()),
               },
             },
           },

+ 33 - 39
packages/opencode/src/server/routes/instance/session.ts

@@ -23,6 +23,7 @@ import { PermissionID } from "@/permission/schema"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
+import { zodObject } from "@/util/effect-zod"
 import { Bus } from "@/bus"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { jsonRequest, runRequest } from "./trace"
@@ -42,7 +43,7 @@ export const SessionRoutes = lazy(() =>
             description: "List of sessions",
             content: {
               "application/json": {
-                schema: resolver(Session.Info.array()),
+                schema: resolver(Session.Info.zod.array()),
               },
             },
           },
@@ -87,7 +88,7 @@ export const SessionRoutes = lazy(() =>
             description: "Get session status",
             content: {
               "application/json": {
-                schema: resolver(z.record(z.string(), SessionStatus.Info)),
+                schema: resolver(z.record(z.string(), SessionStatus.Info.zod)),
               },
             },
           },
@@ -112,7 +113,7 @@ export const SessionRoutes = lazy(() =>
             description: "Get session",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },
@@ -122,7 +123,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: Session.GetInput,
+          sessionID: Session.GetInput.zod,
         }),
       ),
       async (c) => {
@@ -145,7 +146,7 @@ export const SessionRoutes = lazy(() =>
             description: "List of children",
             content: {
               "application/json": {
-                schema: resolver(Session.Info.array()),
+                schema: resolver(Session.Info.zod.array()),
               },
             },
           },
@@ -155,7 +156,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: Session.ChildrenInput,
+          sessionID: Session.ChildrenInput.zod,
         }),
       ),
       async (c) => {
@@ -177,7 +178,7 @@ export const SessionRoutes = lazy(() =>
             description: "Todo list",
             content: {
               "application/json": {
-                schema: resolver(Todo.Info.array()),
+                schema: resolver(Todo.Info.zod.array()),
               },
             },
           },
@@ -210,13 +211,13 @@ export const SessionRoutes = lazy(() =>
             description: "Successfully created session",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },
         },
       }),
-      validator("json", Session.CreateInput),
+      validator("json", Session.CreateInput.zod),
       async (c) =>
         jsonRequest("SessionRoutes.create", c, function* () {
           const body = c.req.valid("json") ?? {}
@@ -245,7 +246,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: Session.RemoveInput,
+          sessionID: Session.RemoveInput.zod,
         }),
       ),
       async (c) =>
@@ -267,7 +268,7 @@ export const SessionRoutes = lazy(() =>
             description: "Successfully updated session",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },
@@ -375,7 +376,7 @@ export const SessionRoutes = lazy(() =>
             description: "200",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },
@@ -384,14 +385,14 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: Session.ForkInput.shape.sessionID,
+          sessionID: SessionID.zod,
         }),
       ),
-      validator("json", Session.ForkInput.omit({ sessionID: true })),
+      validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })),
       async (c) =>
         jsonRequest("SessionRoutes.fork", c, function* () {
           const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
+          const body = c.req.valid("json") as { messageID?: MessageID }
           const svc = yield* Session.Service
           return yield* svc.fork({ ...body, sessionID })
         }),
@@ -438,7 +439,7 @@ export const SessionRoutes = lazy(() =>
             description: "Successfully shared session",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },
@@ -480,18 +481,13 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: SessionSummary.DiffInput.shape.sessionID,
-        }),
-      ),
-      validator(
-        "query",
-        z.object({
-          messageID: SessionSummary.DiffInput.shape.messageID,
+          sessionID: SessionID.zod,
         }),
       ),
+      validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })),
       async (c) =>
         jsonRequest("SessionRoutes.diff", c, function* () {
-          const query = c.req.valid("query")
+          const query = c.req.valid("query") as Omit<SessionSummary.DiffInput, "sessionID">
           const params = c.req.valid("param")
           const summary = yield* SessionSummary.Service
           return yield* summary.diff({
@@ -511,7 +507,7 @@ export const SessionRoutes = lazy(() =>
             description: "Successfully unshared session",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },
@@ -872,7 +868,7 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
         }),
       ),
-      validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
+      validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
       async (c) => {
         c.status(200)
         c.header("Content-Type", "application/json")
@@ -910,7 +906,7 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
         }),
       ),
-      validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
+      validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const body = c.req.valid("json")
@@ -960,11 +956,11 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
         }),
       ),
-      validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
+      validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })),
       async (c) =>
         jsonRequest("SessionRoutes.command", c, function* () {
           const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
+          const body = c.req.valid("json") as Omit<SessionPrompt.CommandInput, "sessionID">
           const svc = yield* SessionPrompt.Service
           return yield* svc.command({ ...body, sessionID })
         }),
@@ -993,11 +989,11 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
         }),
       ),
-      validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
+      validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })),
       async (c) =>
         jsonRequest("SessionRoutes.shell", c, function* () {
           const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
+          const body = c.req.valid("json") as Omit<SessionPrompt.ShellInput, "sessionID">
           const svc = yield* SessionPrompt.Service
           return yield* svc.shell({ ...body, sessionID })
         }),
@@ -1013,7 +1009,7 @@ export const SessionRoutes = lazy(() =>
             description: "Updated session",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },
@@ -1026,16 +1022,14 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
         }),
       ),
-      validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
+      validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })),
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
-        log.info("revert", c.req.valid("json"))
+        const body = c.req.valid("json") as Omit<SessionRevert.RevertInput, "sessionID">
+        log.info("revert", body)
         return jsonRequest("SessionRoutes.revert", c, function* () {
           const svc = yield* SessionRevert.Service
-          return yield* svc.revert({
-            sessionID,
-            ...c.req.valid("json"),
-          })
+          return yield* svc.revert({ sessionID, ...body })
         })
       },
     )
@@ -1050,7 +1044,7 @@ export const SessionRoutes = lazy(() =>
             description: "Updated session",
             content: {
               "application/json": {
-                schema: resolver(Session.Info),
+                schema: resolver(Session.Info.zod),
               },
             },
           },

+ 185 - 184
packages/opencode/src/session/message.ts

@@ -1,191 +1,192 @@
-import z from "zod"
+import { Schema } from "effect"
 import { SessionID } from "./schema"
 import { ModelID, ProviderID } from "../provider/schema"
-import { NamedError } from "@opencode-ai/shared/util/error"
-
-export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
-export const AuthError = NamedError.create(
-  "ProviderAuthError",
-  z.object({
-    providerID: z.string(),
-    message: z.string(),
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
+import { namedSchemaError } from "@/util/named-schema-error"
+
+export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
+export const AuthError = namedSchemaError("ProviderAuthError", {
+  providerID: Schema.String,
+  message: Schema.String,
+})
+
+const AuthErrorEffect = Schema.Struct({
+  name: Schema.Literal("ProviderAuthError"),
+  data: Schema.Struct({
+    providerID: Schema.String,
+    message: Schema.String,
+  }),
+})
+
+const OutputLengthErrorEffect = Schema.Struct({
+  name: Schema.Literal("MessageOutputLengthError"),
+  data: Schema.Struct({}),
+})
+
+const UnknownErrorEffect = Schema.Struct({
+  name: Schema.Literal("UnknownError"),
+  data: Schema.Struct({
+    message: Schema.String,
   }),
-)
-
-export const ToolCall = z
-  .object({
-    state: z.literal("call"),
-    step: z.number().optional(),
-    toolCallId: z.string(),
-    toolName: z.string(),
-    args: z.custom<Required<unknown>>(),
-  })
-  .meta({
-    ref: "ToolCall",
-  })
-export type ToolCall = z.infer<typeof ToolCall>
-
-export const ToolPartialCall = z
-  .object({
-    state: z.literal("partial-call"),
-    step: z.number().optional(),
-    toolCallId: z.string(),
-    toolName: z.string(),
-    args: z.custom<Required<unknown>>(),
-  })
-  .meta({
-    ref: "ToolPartialCall",
-  })
-export type ToolPartialCall = z.infer<typeof ToolPartialCall>
-
-export const ToolResult = z
-  .object({
-    state: z.literal("result"),
-    step: z.number().optional(),
-    toolCallId: z.string(),
-    toolName: z.string(),
-    args: z.custom<Required<unknown>>(),
-    result: z.string(),
-  })
-  .meta({
-    ref: "ToolResult",
-  })
-export type ToolResult = z.infer<typeof ToolResult>
-
-export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
-  ref: "ToolInvocation",
 })
-export type ToolInvocation = z.infer<typeof ToolInvocation>
-
-export const TextPart = z
-  .object({
-    type: z.literal("text"),
-    text: z.string(),
-  })
-  .meta({
-    ref: "TextPart",
-  })
-export type TextPart = z.infer<typeof TextPart>
-
-export const ReasoningPart = z
-  .object({
-    type: z.literal("reasoning"),
-    text: z.string(),
-    providerMetadata: z.record(z.string(), z.any()).optional(),
-  })
-  .meta({
-    ref: "ReasoningPart",
-  })
-export type ReasoningPart = z.infer<typeof ReasoningPart>
-
-export const ToolInvocationPart = z
-  .object({
-    type: z.literal("tool-invocation"),
-    toolInvocation: ToolInvocation,
-  })
-  .meta({
-    ref: "ToolInvocationPart",
-  })
-export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
-
-export const SourceUrlPart = z
-  .object({
-    type: z.literal("source-url"),
-    sourceId: z.string(),
-    url: z.string(),
-    title: z.string().optional(),
-    providerMetadata: z.record(z.string(), z.any()).optional(),
-  })
-  .meta({
-    ref: "SourceUrlPart",
-  })
-export type SourceUrlPart = z.infer<typeof SourceUrlPart>
-
-export const FilePart = z
-  .object({
-    type: z.literal("file"),
-    mediaType: z.string(),
-    filename: z.string().optional(),
-    url: z.string(),
-  })
-  .meta({
-    ref: "FilePart",
-  })
-export type FilePart = z.infer<typeof FilePart>
-
-export const StepStartPart = z
-  .object({
-    type: z.literal("step-start"),
-  })
-  .meta({
-    ref: "StepStartPart",
-  })
-export type StepStartPart = z.infer<typeof StepStartPart>
-
-export const MessagePart = z
-  .discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
-  .meta({
-    ref: "MessagePart",
-  })
-export type MessagePart = z.infer<typeof MessagePart>
-
-export const Info = z
-  .object({
-    id: z.string(),
-    role: z.enum(["user", "assistant"]),
-    parts: z.array(MessagePart),
-    metadata: z
-      .object({
-        time: z.object({
-          created: z.number(),
-          completed: z.number().optional(),
+
+export const ToolCall = Schema.Struct({
+  state: Schema.Literal("call"),
+  step: Schema.optional(Schema.Number),
+  toolCallId: Schema.String,
+  toolName: Schema.String,
+  args: Schema.Unknown,
+})
+  .annotate({ identifier: "ToolCall" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolCall = Schema.Schema.Type<typeof ToolCall>
+
+export const ToolPartialCall = Schema.Struct({
+  state: Schema.Literal("partial-call"),
+  step: Schema.optional(Schema.Number),
+  toolCallId: Schema.String,
+  toolName: Schema.String,
+  args: Schema.Unknown,
+})
+  .annotate({ identifier: "ToolPartialCall" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
+
+export const ToolResult = Schema.Struct({
+  state: Schema.Literal("result"),
+  step: Schema.optional(Schema.Number),
+  toolCallId: Schema.String,
+  toolName: Schema.String,
+  args: Schema.Unknown,
+  result: Schema.String,
+})
+  .annotate({ identifier: "ToolResult" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolResult = Schema.Schema.Type<typeof ToolResult>
+
+export const ToolInvocation = Schema.Union([ToolCall, ToolPartialCall, ToolResult])
+  .annotate({ identifier: "ToolInvocation", discriminator: "state" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolInvocation = Schema.Schema.Type<typeof ToolInvocation>
+
+export const TextPart = Schema.Struct({
+  type: Schema.Literal("text"),
+  text: Schema.String,
+})
+  .annotate({ identifier: "TextPart" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type TextPart = Schema.Schema.Type<typeof TextPart>
+
+export const ReasoningPart = Schema.Struct({
+  type: Schema.Literal("reasoning"),
+  text: Schema.String,
+  providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+})
+  .annotate({ identifier: "ReasoningPart" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
+
+export const ToolInvocationPart = Schema.Struct({
+  type: Schema.Literal("tool-invocation"),
+  toolInvocation: ToolInvocation,
+})
+  .annotate({ identifier: "ToolInvocationPart" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolInvocationPart = Schema.Schema.Type<typeof ToolInvocationPart>
+
+export const SourceUrlPart = Schema.Struct({
+  type: Schema.Literal("source-url"),
+  sourceId: Schema.String,
+  url: Schema.String,
+  title: Schema.optional(Schema.String),
+  providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+})
+  .annotate({ identifier: "SourceUrlPart" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type SourceUrlPart = Schema.Schema.Type<typeof SourceUrlPart>
+
+export const FilePart = Schema.Struct({
+  type: Schema.Literal("file"),
+  mediaType: Schema.String,
+  filename: Schema.optional(Schema.String),
+  url: Schema.String,
+})
+  .annotate({ identifier: "FilePart" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FilePart = Schema.Schema.Type<typeof FilePart>
+
+export const StepStartPart = Schema.Struct({
+  type: Schema.Literal("step-start"),
+})
+  .annotate({ identifier: "StepStartPart" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type StepStartPart = Schema.Schema.Type<typeof StepStartPart>
+
+export const MessagePart = Schema.Union([
+  TextPart,
+  ReasoningPart,
+  ToolInvocationPart,
+  SourceUrlPart,
+  FilePart,
+  StepStartPart,
+])
+  .annotate({ identifier: "MessagePart", discriminator: "type" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type MessagePart = Schema.Schema.Type<typeof MessagePart>
+
+export const Info = Schema.Struct({
+  id: Schema.String,
+  role: Schema.Literals(["user", "assistant"]),
+  parts: Schema.Array(MessagePart),
+  metadata: Schema.Struct({
+    time: Schema.Struct({
+      created: Schema.Number,
+      completed: Schema.optional(Schema.Number),
+    }),
+    error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
+    sessionID: SessionID,
+    tool: Schema.Record(
+      Schema.String,
+      Schema.StructWithRest(
+        Schema.Struct({
+          title: Schema.String,
+          snapshot: Schema.optional(Schema.String),
+          time: Schema.Struct({
+            start: Schema.Number,
+            end: Schema.Number,
+          }),
+        }),
+        [Schema.Record(Schema.String, Schema.Unknown)],
+      ),
+    ),
+    assistant: Schema.optional(
+      Schema.Struct({
+        system: Schema.Array(Schema.String),
+        modelID: ModelID,
+        providerID: ProviderID,
+        path: Schema.Struct({
+          cwd: Schema.String,
+          root: Schema.String,
         }),
-        error: z
-          .discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
-          .optional(),
-        sessionID: SessionID.zod,
-        tool: z.record(
-          z.string(),
-          z
-            .object({
-              title: z.string(),
-              snapshot: z.string().optional(),
-              time: z.object({
-                start: z.number(),
-                end: z.number(),
-              }),
-            })
-            .catchall(z.any()),
-        ),
-        assistant: z
-          .object({
-            system: z.string().array(),
-            modelID: ModelID.zod,
-            providerID: ProviderID.zod,
-            path: z.object({
-              cwd: z.string(),
-              root: z.string(),
-            }),
-            cost: z.number(),
-            summary: z.boolean().optional(),
-            tokens: z.object({
-              input: z.number(),
-              output: z.number(),
-              reasoning: z.number(),
-              cache: z.object({
-                read: z.number(),
-                write: z.number(),
-              }),
-            }),
-          })
-          .optional(),
-        snapshot: z.string().optional(),
-      })
-      .meta({ ref: "MessageMetadata" }),
-  })
-  .meta({
-    ref: "Message",
-  })
-export type Info = z.infer<typeof Info>
+        cost: Schema.Number,
+        summary: Schema.optional(Schema.Boolean),
+        tokens: Schema.Struct({
+          input: Schema.Number,
+          output: Schema.Number,
+          reasoning: Schema.Number,
+          cache: Schema.Struct({
+            read: Schema.Number,
+            write: Schema.Number,
+          }),
+        }),
+      }),
+    ),
+    snapshot: Schema.optional(Schema.String),
+  }).annotate({ identifier: "MessageMetadata" }),
+})
+  .annotate({ identifier: "Message" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
 
 export * as Message from "./message"

+ 1 - 1
packages/opencode/src/session/projectors.ts

@@ -62,7 +62,7 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
 
 export default [
   SyncEvent.project(Session.Event.Created, (db, data) => {
-    db.insert(SessionTable).values(Session.toRow(data.info)).run()
+    db.insert(SessionTable).values(Session.toRow(data.info as Session.Info)).run()
   }),
 
   SyncEvent.project(Session.Event.Updated, (db, data) => {

+ 75 - 76
packages/opencode/src/session/prompt.ts

@@ -43,7 +43,9 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Truncate } from "@/tool"
 import { decodeDataUrl } from "@/util/data-url"
 import { Process } from "@/util"
-import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
+import { Cause, Effect, Exit, Layer, Option, Scope, Context, Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import { EffectLogger } from "@/effect"
 import { InstanceState } from "@/effect"
 import { TaskTool, type TaskPromptOps } from "@/tool/task"
@@ -69,7 +71,7 @@ const elog = EffectLogger.create({ service: "session.prompt" })
 export interface Interface {
   readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
   readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
-  readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
+  readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
   readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
   readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
   readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
@@ -1532,9 +1534,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       },
     )
 
-    const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
-      "SessionPrompt.loop",
-    )(function* (input: z.infer<typeof LoopInput>) {
+    const loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.loop")(function* (
+      input: LoopInput,
+    ) {
       return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
     })
 
@@ -1701,91 +1703,88 @@ export const defaultLayer = Layer.suspend(() =>
     ),
   ),
 )
-export const PromptInput = z.object({
-  sessionID: SessionID.zod,
-  messageID: MessageID.zod.optional(),
-  model: z
-    .object({
-      providerID: ProviderID.zod,
-      modelID: ModelID.zod,
-    })
-    .optional(),
-  agent: z.string().optional(),
-  noReply: z.boolean().optional(),
-  tools: z
-    .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.zod.optional(),
-  system: z.string().optional(),
-  variant: z.string().optional(),
-  parts: z.array(
-    z.discriminatedUnion("type", [
-      MessageV2.TextPartInput.zod as unknown as z.ZodObject<any>,
-      MessageV2.FilePartInput.zod as unknown as z.ZodObject<any>,
-      MessageV2.AgentPartInput.zod as unknown as z.ZodObject<any>,
-      MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject<any>,
-    ]),
-  ),
+const ModelRef = Schema.Struct({
+  providerID: ProviderID,
+  modelID: ModelID,
 })
+
+export const PromptInput = Schema.Struct({
+  sessionID: SessionID,
+  messageID: Schema.optional(MessageID),
+  model: Schema.optional(ModelRef),
+  agent: Schema.optional(Schema.String),
+  noReply: Schema.optional(Schema.Boolean),
+  tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
+    description:
+      "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
+  }),
+  format: Schema.optional(MessageV2.Format),
+  system: Schema.optional(Schema.String),
+  variant: Schema.optional(Schema.String),
+  parts: Schema.Array(
+    Schema.Union([
+      MessageV2.TextPartInput,
+      MessageV2.FilePartInput,
+      MessageV2.AgentPartInput,
+      MessageV2.SubtaskPartInput,
+    ]).annotate({ discriminator: "type" }),
+  ),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 // `z.discriminatedUnion` erases the discriminated members' shapes back to
-// `{}` because the derived `.zod` on each input is typed as an opaque
-// `z.ZodType`. Restore the precise `parts` type from the exported Schema
-// input types so callers see a proper tagged union.
+// `{}` when walked from the generic `z.ZodType` input. Restore the precise
+// `parts` type from the exported Schema input types so callers see a proper
+// tagged union.
 type PartInputUnion =
   | MessageV2.TextPartInput
   | MessageV2.FilePartInput
   | MessageV2.AgentPartInput
   | MessageV2.SubtaskPartInput
-export type PromptInput = Omit<z.infer<typeof PromptInput>, "parts"> & {
+export type PromptInput = Omit<Schema.Schema.Type<typeof PromptInput>, "parts"> & {
   parts: PartInputUnion[]
 }
 
-export const LoopInput = z.object({
-  sessionID: SessionID.zod,
-})
+export class LoopInput extends Schema.Class<LoopInput>("SessionPrompt.LoopInput")({
+  sessionID: SessionID,
+}) {
+  static readonly zod = zod(this)
+}
 
-export const ShellInput = z.object({
-  sessionID: SessionID.zod,
-  messageID: MessageID.zod.optional(),
-  agent: z.string(),
-  model: z
-    .object({
-      providerID: ProviderID.zod,
-      modelID: ModelID.zod,
-    })
-    .optional(),
-  command: z.string(),
-})
-export type ShellInput = z.infer<typeof ShellInput>
-
-export const CommandInput = z.object({
-  messageID: MessageID.zod.optional(),
-  sessionID: SessionID.zod,
-  agent: z.string().optional(),
-  model: z.string().optional(),
-  arguments: z.string(),
-  command: z.string(),
-  variant: z.string().optional(),
-  // Inlined (no `.meta({ ref })`) to keep the original SDK output — the
+export const ShellInput = Schema.Struct({
+  sessionID: SessionID,
+  messageID: Schema.optional(MessageID),
+  agent: Schema.String,
+  model: Schema.optional(ModelRef),
+  command: Schema.String,
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ShellInput = Schema.Schema.Type<typeof ShellInput>
+
+export const CommandInput = Schema.Struct({
+  messageID: Schema.optional(MessageID),
+  sessionID: SessionID,
+  agent: Schema.optional(Schema.String),
+  model: Schema.optional(Schema.String),
+  arguments: Schema.String,
+  command: Schema.String,
+  variant: Schema.optional(Schema.String),
+  // Inlined (no identifier annotation) to keep the original SDK output — the
   // PromptInput call site below references FilePartInput by ref via the
   // Schema export in message-v2.ts.
-  parts: z
-    .array(
-      z.discriminatedUnion("type", [
-        z.object({
-          id: PartID.zod.optional(),
-          type: z.literal("file"),
-          mime: z.string(),
-          filename: z.string().optional(),
-          url: z.string(),
-          source: MessageV2.FilePartSource.zod.optional(),
+  parts: Schema.optional(
+    Schema.Array(
+      Schema.Union([
+        Schema.Struct({
+          id: Schema.optional(PartID),
+          type: Schema.Literal("file"),
+          mime: Schema.String,
+          filename: Schema.optional(Schema.String),
+          url: Schema.String,
+          source: Schema.optional(MessageV2.FilePartSource),
         }),
-      ]),
-    )
-    .optional(),
-})
-export type CommandInput = z.infer<typeof CommandInput>
+      ]).annotate({ discriminator: "type" }),
+    ),
+  ),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type CommandInput = Schema.Schema.Type<typeof CommandInput>
 
 /** @internal Exported for testing */
 export function createStructuredOutputTool(input: {

+ 9 - 8
packages/opencode/src/session/revert.ts

@@ -1,10 +1,11 @@
-import z from "zod"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Schema } from "effect"
 import { Bus } from "../bus"
 import { Snapshot } from "../snapshot"
 import { Storage } from "@/storage"
 import { SyncEvent } from "../sync"
 import { Log } from "../util"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import * as Session from "./session"
 import { MessageV2 } from "./message-v2"
 import { SessionID, MessageID, PartID } from "./schema"
@@ -13,12 +14,12 @@ import { SessionSummary } from "./summary"
 
 const log = Log.create({ service: "session.revert" })
 
-export const RevertInput = z.object({
-  sessionID: SessionID.zod,
-  messageID: MessageID.zod,
-  partID: PartID.zod.optional(),
-})
-export type RevertInput = z.infer<typeof RevertInput>
+export const RevertInput = Schema.Struct({
+  sessionID: SessionID,
+  messageID: MessageID,
+  partID: Schema.optional(PartID),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type RevertInput = Schema.Schema.Type<typeof RevertInput>
 
 export interface Interface {
   readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>

+ 103 - 88
packages/opencode/src/session/session.ts

@@ -27,7 +27,9 @@ import { SessionID, MessageID, PartID } from "./schema"
 import type { Provider } from "@/provider"
 import { Permission } from "@/permission"
 import { Global } from "@/global"
-import { Effect, Layer, Option, Context } from "effect"
+import { Effect, Layer, Option, Context, Schema, Types } from "effect"
+import { zod, zodObject } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 const log = Log.create({ service: "session" })
 
@@ -114,91 +116,104 @@ function getForkedTitle(title: string): string {
   return `${title} (fork #1)`
 }
 
-export const Info = z
-  .object({
-    id: SessionID.zod,
-    slug: z.string(),
-    projectID: ProjectID.zod,
-    workspaceID: WorkspaceID.zod.optional(),
-    directory: z.string(),
-    parentID: SessionID.zod.optional(),
-    summary: z
-      .object({
-        additions: z.number(),
-        deletions: z.number(),
-        files: z.number(),
-        diffs: Snapshot.FileDiff.zod.array().optional(),
-      })
-      .optional(),
-    share: z
-      .object({
-        url: z.string(),
-      })
-      .optional(),
-    title: z.string(),
-    version: z.string(),
-    time: z.object({
-      created: z.number(),
-      updated: z.number(),
-      compacting: z.number().optional(),
-      archived: z.number().optional(),
-    }),
-    permission: Permission.Ruleset.zod.optional(),
-    revert: z
-      .object({
-        messageID: MessageID.zod,
-        partID: PartID.zod.optional(),
-        snapshot: z.string().optional(),
-        diff: z.string().optional(),
-      })
-      .optional(),
-  })
-  .meta({
-    ref: "Session",
-  })
-export type Info = z.output<typeof Info>
+const Summary = Schema.Struct({
+  additions: Schema.Number,
+  deletions: Schema.Number,
+  files: Schema.Number,
+  diffs: Schema.optional(Schema.Array(Snapshot.FileDiff)),
+})
 
-export const ProjectInfo = z
-  .object({
-    id: ProjectID.zod,
-    name: z.string().optional(),
-    worktree: z.string(),
-  })
-  .meta({
-    ref: "ProjectSummary",
-  })
-export type ProjectInfo = z.output<typeof ProjectInfo>
+const Share = Schema.Struct({
+  url: Schema.String,
+})
 
-export const GlobalInfo = Info.extend({
-  project: ProjectInfo.nullable(),
-}).meta({
-  ref: "GlobalSession",
+const Time = Schema.Struct({
+  created: Schema.Number,
+  updated: Schema.Number,
+  compacting: Schema.optional(Schema.Number),
+  archived: Schema.optional(Schema.Number),
 })
-export type GlobalInfo = z.output<typeof GlobalInfo>
-
-export const CreateInput = z
-  .object({
-    parentID: SessionID.zod.optional(),
-    title: z.string().optional(),
-    permission: Info.shape.permission,
-    workspaceID: WorkspaceID.zod.optional(),
-  })
-  .optional()
-export type CreateInput = z.output<typeof CreateInput>
-
-export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() })
-export const GetInput = SessionID.zod
-export const ChildrenInput = SessionID.zod
-export const RemoveInput = SessionID.zod
-export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
-export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
-export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod })
-export const SetRevertInput = z.object({
-  sessionID: SessionID.zod,
-  revert: Info.shape.revert,
-  summary: Info.shape.summary,
+
+const Revert = Schema.Struct({
+  messageID: MessageID,
+  partID: Schema.optional(PartID),
+  snapshot: Schema.optional(Schema.String),
+  diff: Schema.optional(Schema.String),
+})
+
+export const Info = Schema.Struct({
+  id: SessionID,
+  slug: Schema.String,
+  projectID: ProjectID,
+  workspaceID: Schema.optional(WorkspaceID),
+  directory: Schema.String,
+  parentID: Schema.optional(SessionID),
+  summary: Schema.optional(Summary),
+  share: Schema.optional(Share),
+  title: Schema.String,
+  version: Schema.String,
+  time: Time,
+  permission: Schema.optional(Permission.Ruleset),
+  revert: Schema.optional(Revert),
+})
+  .annotate({ identifier: "Session" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
+
+export const ProjectInfo = Schema.Struct({
+  id: ProjectID,
+  name: Schema.optional(Schema.String),
+  worktree: Schema.String,
 })
-export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
+  .annotate({ identifier: "ProjectSummary" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ProjectInfo = Types.DeepMutable<Schema.Schema.Type<typeof ProjectInfo>>
+
+export const GlobalInfo = Schema.Struct({
+  ...Info.fields,
+  project: Schema.NullOr(ProjectInfo),
+})
+  .annotate({ identifier: "GlobalSession" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type GlobalInfo = Types.DeepMutable<Schema.Schema.Type<typeof GlobalInfo>>
+
+export const CreateInput = Schema.optional(
+  Schema.Struct({
+    parentID: Schema.optional(SessionID),
+    title: Schema.optional(Schema.String),
+    permission: Schema.optional(Permission.Ruleset),
+    workspaceID: Schema.optional(WorkspaceID),
+  }),
+).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
+
+export const ForkInput = Schema.Struct({
+  sessionID: SessionID,
+  messageID: Schema.optional(MessageID),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export const GetInput = SessionID
+export const ChildrenInput = SessionID
+export const RemoveInput = SessionID
+export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe(
+  withStatics((s) => ({ zod: zod(s) })),
+)
+export const SetArchivedInput = Schema.Struct({
+  sessionID: SessionID,
+  time: Schema.optional(Schema.Number),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export const SetPermissionInput = Schema.Struct({
+  sessionID: SessionID,
+  permission: Permission.Ruleset,
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export const SetRevertInput = Schema.Struct({
+  sessionID: SessionID,
+  revert: Schema.optional(Revert),
+  summary: Schema.optional(Summary),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export const MessagesInput = Schema.Struct({
+  sessionID: SessionID,
+  limit: Schema.optional(Schema.Number),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 export const Event = {
   Created: SyncEvent.define({
@@ -207,7 +222,7 @@ export const Event = {
     aggregate: "sessionID",
     schema: z.object({
       sessionID: SessionID.zod,
-      info: Info,
+      info: Info.zod,
     }),
   }),
   Updated: SyncEvent.define({
@@ -216,14 +231,14 @@ export const Event = {
     aggregate: "sessionID",
     schema: z.object({
       sessionID: SessionID.zod,
-      info: updateSchema(Info).extend({
-        share: updateSchema(Info.shape.share.unwrap()).optional(),
-        time: updateSchema(Info.shape.time).optional(),
+      info: updateSchema(zodObject(Info)).extend({
+        share: updateSchema(zodObject(Share)).optional(),
+        time: updateSchema(zodObject(Time)).optional(),
       }),
     }),
     busSchema: z.object({
       sessionID: SessionID.zod,
-      info: Info,
+      info: Info.zod,
     }),
   }),
   Deleted: SyncEvent.define({
@@ -232,7 +247,7 @@ export const Event = {
     aggregate: "sessionID",
     schema: z.object({
       sessionID: SessionID.zod,
-      info: Info,
+      info: Info.zod,
     }),
   }),
   Diff: BusEvent.define(

+ 21 - 21
packages/opencode/src/session/status.ts

@@ -2,35 +2,35 @@ import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { InstanceState } from "@/effect"
 import { SessionID } from "./schema"
-import { Effect, Layer, Context } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
+import { Effect, Layer, Context, Schema } from "effect"
 import z from "zod"
 
-export const Info = z
-  .union([
-    z.object({
-      type: z.literal("idle"),
-    }),
-    z.object({
-      type: z.literal("retry"),
-      attempt: z.number(),
-      message: z.string(),
-      next: z.number(),
-    }),
-    z.object({
-      type: z.literal("busy"),
-    }),
-  ])
-  .meta({
-    ref: "SessionStatus",
-  })
-export type Info = z.infer<typeof Info>
+export const Info = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("idle"),
+  }),
+  Schema.Struct({
+    type: Schema.Literal("retry"),
+    attempt: Schema.Number,
+    message: Schema.String,
+    next: Schema.Number,
+  }),
+  Schema.Struct({
+    type: Schema.Literal("busy"),
+  }),
+])
+  .annotate({ identifier: "SessionStatus" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
 
 export const Event = {
   Status: BusEvent.define(
     "session.status",
     z.object({
       sessionID: SessionID.zod,
-      status: Info,
+      status: Info.zod,
     }),
   ),
   // deprecated

+ 8 - 6
packages/opencode/src/session/summary.ts

@@ -1,8 +1,9 @@
-import z from "zod"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Schema } from "effect"
 import { Bus } from "@/bus"
 import { Snapshot } from "@/snapshot"
 import { Storage } from "@/storage"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import * as Session from "./session"
 import { MessageV2 } from "./message-v2"
 import { SessionID, MessageID } from "./schema"
@@ -155,9 +156,10 @@ export const defaultLayer = Layer.suspend(() =>
   ),
 )
 
-export const DiffInput = z.object({
-  sessionID: SessionID.zod,
-  messageID: MessageID.zod.optional(),
-})
+export const DiffInput = Schema.Struct({
+  sessionID: SessionID,
+  messageID: Schema.optional(MessageID),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type DiffInput = Schema.Schema.Type<typeof DiffInput>
 
 export * as SessionSummary from "./summary"

+ 14 - 10
packages/opencode/src/session/todo.ts

@@ -1,26 +1,30 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { SessionID } from "./schema"
-import { Effect, Layer, Context } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
+import { Effect, Layer, Context, Schema } from "effect"
 import z from "zod"
 import { Database, eq, asc } from "../storage"
 import { TodoTable } from "./session.sql"
 
-export const Info = z
-  .object({
-    content: z.string().describe("Brief description of the task"),
-    status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
-    priority: z.string().describe("Priority level of the task: high, medium, low"),
-  })
-  .meta({ ref: "Todo" })
-export type Info = z.infer<typeof Info>
+export const Info = Schema.Struct({
+  content: Schema.String.annotate({ description: "Brief description of the task" }),
+  status: Schema.String.annotate({
+    description: "Current status of the task: pending, in_progress, completed, cancelled",
+  }),
+  priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
+})
+  .annotate({ identifier: "Todo" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
 
 export const Event = {
   Updated: BusEvent.define(
     "todo.updated",
     z.object({
       sessionID: SessionID.zod,
-      todos: z.array(Info),
+      todos: z.array(Info.zod),
     }),
   ),
 }

+ 14 - 1
packages/opencode/src/tool/todo.ts

@@ -4,8 +4,21 @@ import * as Tool from "./tool"
 import DESCRIPTION_WRITE from "./todowrite.txt"
 import { Todo } from "../session/todo"
 
+// Parameters are kept inline rather than derived from Todo.Info because
+// Tool.define requires z.ZodObject-typed parameters for execute() inference,
+// and zodObject(Todo.Info) returns ZodObject<any> — reaching into .shape would
+// erase field types. Tool schemas migrate to Effect Schema as a separate slice
+// per specs/effect/schema.md.
 const parameters = z.object({
-  todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
+  todos: z
+    .array(
+      z.object({
+        content: z.string().describe("Brief description of the task"),
+        status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
+        priority: z.string().describe("Priority level of the task: high, medium, low"),
+      }),
+    )
+    .describe("The updated todo list"),
 })
 
 type Metadata = {

+ 27 - 0
packages/opencode/src/util/effect-zod.ts

@@ -22,6 +22,33 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
   return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
 }
 
+/**
+ * Derive a Zod value from an Effect Schema (or a Schema-backed export with a
+ * `.zod` static) and narrow the result to `z.ZodObject<any>` so `.shape`,
+ * `.omit`, `.extend`, and friends are accessible.
+ *
+ * The `zod()` walker returns `z.ZodType<T>` because not every AST node decodes
+ * to an object; this helper keeps the "I started from a `Schema.Struct`" cast
+ * in one place instead of sprinkling `as unknown as z.ZodObject<any>` across
+ * call sites.
+ *
+ * The return is intentionally loose — carrying Schema field types through the
+ * mapped `.omit()` / `.extend()` surface triggers brand-intersection
+ * explosions for branded primitives (`string & Brand<"SessionID">` extends
+ * `object` via the brand and gets walked into the prototype by `DeepPartial`,
+ * `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
+ * wrappers also can't reconstruct `T` cleanly. Consumers that care about the
+ * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
+ */
+export function zodObject<S extends Schema.Top>(schema: S): z.ZodObject<any> {
+  const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast)
+  return derived as unknown as z.ZodObject<any>
+}
+
+function isZodType(value: unknown): value is z.ZodTypeAny {
+  return typeof value === "object" && value !== null && "_zod" in value
+}
+
 function walk(ast: SchemaAST.AST): z.ZodTypeAny {
   const cached = walkCache.get(ast)
   if (cached) return cached

+ 1 - 1
packages/opencode/test/server/global-session-list.test.ts

@@ -18,7 +18,7 @@ const svc = {
   create(input?: SessionNs.CreateInput) {
     return run(SessionNs.Service.use((svc) => svc.create(input)))
   },
-  setArchived(input: z.output<typeof SessionNs.SetArchivedInput>) {
+  setArchived(input: z.output<typeof SessionNs.SetArchivedInput.zod>) {
     return run(SessionNs.Service.use((svc) => svc.setArchived(input)))
   },
 }

+ 1 - 1
packages/opencode/test/session/compaction.test.ts

@@ -38,7 +38,7 @@ const svc = {
   create(input?: SessionNs.CreateInput) {
     return run(SessionNs.Service.use((svc) => svc.create(input)))
   },
-  messages(input: z.output<typeof SessionNs.MessagesInput>) {
+  messages(input: z.output<typeof SessionNs.MessagesInput.zod>) {
     return run(SessionNs.Service.use((svc) => svc.messages(input)))
   },
   updateMessage<T extends MessageV2.Info>(msg: T) {

+ 310 - 0
packages/opencode/test/session/schema-decoding.test.ts

@@ -0,0 +1,310 @@
+import { describe, expect, test } from "bun:test"
+import { Schema } from "effect"
+
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { SessionRevert } from "../../src/session/revert"
+import { SessionStatus } from "../../src/session/status"
+import { SessionSummary } from "../../src/session/summary"
+import { Todo } from "../../src/session/todo"
+import { SessionID, MessageID, PartID } from "../../src/session/schema"
+import { ProjectID } from "../../src/project/schema"
+import { WorkspaceID } from "../../src/control-plane/schema"
+
+// Covers the session-domain Effect Schema migration. For each migrated
+// schema we assert:
+//   1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input.
+//   2. The derived Zod (`X.zod.parse`) accepts the same input and returns the
+//      same shape.
+//   3. Clearly-invalid input is rejected by both paths.
+//
+// The point is to lock down the Schema <-> Zod bridge so a future edit to
+// any input schema can't silently drop or widen a field on one side.
+
+// Representative valid IDs — the branded schemas require the right prefix
+// (see src/id/id.ts).
+const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K")
+const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
+const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
+const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
+const projectID = ProjectID.zod.parse("proj-alpha")
+const workspaceID = WorkspaceID.zod.parse("wrk-primary")
+
+function decodeUnknown<S extends Schema.Top>(schema: S) {
+  const decode = Schema.decodeUnknownSync(schema as any)
+  return (input: unknown): Schema.Schema.Type<S> => decode(input) as Schema.Schema.Type<S>
+}
+
+describe("Session.Info", () => {
+  const decode = decodeUnknown(Session.Info)
+
+  test("accepts minimal session", () => {
+    const input = {
+      id: sessionID,
+      slug: "hello",
+      projectID,
+      directory: "/tmp/proj",
+      title: "First session",
+      version: "0.1.0",
+      time: { created: 1, updated: 2 },
+    }
+    expect(decode(input)).toEqual(input)
+    expect(Session.Info.zod.parse(input)).toEqual(input)
+  })
+
+  test("round-trips every optional field", () => {
+    const input = {
+      id: sessionID,
+      slug: "fullshape",
+      projectID,
+      workspaceID,
+      directory: "/tmp/proj",
+      parentID: sessionIDChild,
+      summary: {
+        additions: 10,
+        deletions: 5,
+        files: 2,
+        diffs: [{ additions: 1, deletions: 0, file: "a.ts", patch: "--- a/a.ts" }],
+      },
+      share: { url: "https://share.example.com/s/1" },
+      title: "Full session",
+      version: "1.0.0",
+      time: { created: 100, updated: 200, compacting: 150, archived: 300 },
+      permission: [{ action: "allow" as const, pattern: "*", permission: "read" }],
+      revert: {
+        messageID,
+        partID,
+        snapshot: "snap-1",
+        diff: "diff-1",
+      },
+    }
+    expect(decode(input)).toEqual(input)
+    expect(Session.Info.zod.parse(input)).toEqual(input)
+  })
+
+  test("rejects unbranded session id", () => {
+    const bad = { id: "not-a-session-id" } as unknown
+    expect(() => decode(bad)).toThrow()
+    expect(() => Session.Info.zod.parse(bad)).toThrow()
+  })
+
+  test("rejects missing required fields", () => {
+    const bad = { id: sessionID } as unknown
+    expect(() => decode(bad)).toThrow()
+    expect(() => Session.Info.zod.parse(bad)).toThrow()
+  })
+})
+
+describe("Session.ProjectInfo", () => {
+  const decode = decodeUnknown(Session.ProjectInfo)
+
+  test("accepts with and without optional name", () => {
+    const noName = { id: projectID, worktree: "/tmp/wt" }
+    const withName = { ...noName, name: "alpha" }
+    expect(decode(noName)).toEqual(noName)
+    expect(decode(withName)).toEqual(withName)
+    expect(Session.ProjectInfo.zod.parse(noName)).toEqual(noName)
+    expect(Session.ProjectInfo.zod.parse(withName)).toEqual(withName)
+  })
+})
+
+describe("Session.GlobalInfo", () => {
+  const decode = decodeUnknown(Session.GlobalInfo)
+
+  test("accepts null project", () => {
+    const input = {
+      id: sessionID,
+      slug: "global",
+      projectID,
+      directory: "/tmp/proj",
+      title: "global",
+      version: "0",
+      time: { created: 0, updated: 0 },
+      project: null,
+    }
+    expect(decode(input)).toEqual(input)
+    expect(Session.GlobalInfo.zod.parse(input)).toEqual(input)
+  })
+
+  test("accepts populated project", () => {
+    const input = {
+      id: sessionID,
+      slug: "global",
+      projectID,
+      directory: "/tmp/proj",
+      title: "global",
+      version: "0",
+      time: { created: 0, updated: 0 },
+      project: { id: projectID, worktree: "/tmp/wt", name: "alpha" },
+    }
+    expect(decode(input)).toEqual(input)
+    expect(Session.GlobalInfo.zod.parse(input)).toEqual(input)
+  })
+})
+
+describe("Session input schemas", () => {
+  test("CreateInput accepts undefined and populated forms", () => {
+    const decode = decodeUnknown(Session.CreateInput)
+    expect(decode(undefined)).toBeUndefined()
+    expect(Session.CreateInput.zod.parse(undefined)).toBeUndefined()
+
+    const populated = {
+      parentID: sessionID,
+      title: "child",
+      permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }],
+      workspaceID,
+    }
+    expect(decode(populated)).toEqual(populated)
+    expect(Session.CreateInput.zod.parse(populated)).toEqual(populated)
+  })
+
+  test("ForkInput round-trips", () => {
+    const decode = decodeUnknown(Session.ForkInput)
+    const input = { sessionID, messageID }
+    expect(decode(input)).toEqual(input)
+    expect(Session.ForkInput.zod.parse(input)).toEqual(input)
+    // messageID is optional
+    const bare = { sessionID }
+    expect(decode(bare)).toEqual(bare)
+    expect(Session.ForkInput.zod.parse(bare)).toEqual(bare)
+  })
+
+  test("SetTitleInput rejects missing title", () => {
+    expect(() => decodeUnknown(Session.SetTitleInput)({ sessionID })).toThrow()
+    expect(() => Session.SetTitleInput.zod.parse({ sessionID })).toThrow()
+  })
+
+  test("SetArchivedInput accepts both with and without time", () => {
+    const decode = decodeUnknown(Session.SetArchivedInput)
+    expect(decode({ sessionID })).toEqual({ sessionID })
+    expect(decode({ sessionID, time: 123 })).toEqual({ sessionID, time: 123 })
+  })
+
+  test("SetPermissionInput requires a ruleset", () => {
+    const decode = decodeUnknown(Session.SetPermissionInput)
+    const input = { sessionID, permission: [{ action: "deny" as const, pattern: "*", permission: "write" }] }
+    expect(decode(input)).toEqual(input)
+    expect(() => decode({ sessionID })).toThrow()
+  })
+
+  test("MessagesInput accepts optional limit", () => {
+    const decode = decodeUnknown(Session.MessagesInput)
+    expect(decode({ sessionID })).toEqual({ sessionID })
+    expect(decode({ sessionID, limit: 50 })).toEqual({ sessionID, limit: 50 })
+  })
+})
+
+describe("SessionRevert.RevertInput", () => {
+  const decode = decodeUnknown(SessionRevert.RevertInput)
+
+  test("messageID is required, partID is optional", () => {
+    const withPart = { sessionID, messageID, partID }
+    expect(decode(withPart)).toEqual(withPart)
+    expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart)
+
+    const noPart = { sessionID, messageID }
+    expect(decode(noPart)).toEqual(noPart)
+    expect(SessionRevert.RevertInput.zod.parse(noPart)).toEqual(noPart)
+
+    expect(() => decode({ sessionID })).toThrow()
+    expect(() => SessionRevert.RevertInput.zod.parse({ sessionID })).toThrow()
+  })
+})
+
+describe("SessionSummary.DiffInput", () => {
+  const decode = decodeUnknown(SessionSummary.DiffInput)
+
+  test("messageID optional", () => {
+    expect(decode({ sessionID })).toEqual({ sessionID })
+    expect(decode({ sessionID, messageID })).toEqual({ sessionID, messageID })
+  })
+})
+
+describe("SessionStatus.Info", () => {
+  const decode = decodeUnknown(SessionStatus.Info)
+
+  test("idle / busy discriminators", () => {
+    expect(decode({ type: "idle" })).toEqual({ type: "idle" })
+    expect(decode({ type: "busy" })).toEqual({ type: "busy" })
+    expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" })
+  })
+
+  test("retry carries attempt/message/next", () => {
+    const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 }
+    expect(decode(input)).toEqual(input)
+    expect(SessionStatus.Info.zod.parse(input)).toEqual(input)
+  })
+
+  test("rejects unknown type", () => {
+    expect(() => decode({ type: "bogus" })).toThrow()
+    expect(() => SessionStatus.Info.zod.parse({ type: "bogus" })).toThrow()
+  })
+})
+
+describe("Todo.Info", () => {
+  const decode = decodeUnknown(Todo.Info)
+
+  test("three-field round-trip", () => {
+    const input = { content: "do a thing", status: "pending", priority: "high" }
+    expect(decode(input)).toEqual(input)
+    expect(Todo.Info.zod.parse(input)).toEqual(input)
+  })
+})
+
+describe("SessionPrompt input schemas", () => {
+  test("LoopInput is just sessionID", () => {
+    const decode = decodeUnknown(SessionPrompt.LoopInput)
+    expect(decode({ sessionID })).toEqual({ sessionID })
+    expect(SessionPrompt.LoopInput.zod.parse({ sessionID } as unknown)).toEqual({ sessionID })
+  })
+
+  test("ShellInput requires agent + command", () => {
+    const decode = decodeUnknown(SessionPrompt.ShellInput)
+    const expected = { sessionID, agent: "build", command: "echo hi" }
+    const input: unknown = expected
+    expect(decode(input)).toEqual(expected)
+    expect(SessionPrompt.ShellInput.zod.parse(input as unknown)).toEqual(expected)
+    expect(() => decode({ sessionID })).toThrow()
+  })
+
+  test("PromptInput accepts a text part and a file part", () => {
+    const decode = decodeUnknown(SessionPrompt.PromptInput)
+    const expected = {
+      sessionID,
+      parts: [
+        { type: "text" as const, text: "hello" },
+        { type: "file" as const, mime: "image/png", url: "data:image/png;base64,AAAA" },
+      ],
+    }
+    const input: unknown = expected
+    const decoded = decode(input)
+    expect(decoded.parts).toHaveLength(2)
+    expect(decoded.parts[0]).toMatchObject({ type: "text", text: "hello" })
+    expect(decoded.parts[1]).toMatchObject({ type: "file", mime: "image/png" })
+
+    const viaZod = SessionPrompt.PromptInput.zod.parse(input)
+    expect(viaZod.parts).toHaveLength(2)
+  })
+
+  test("PromptInput rejects unknown part type", () => {
+    const decode = decodeUnknown(SessionPrompt.PromptInput)
+    const bad = {
+      sessionID,
+      parts: [{ type: "nonsense", payload: 42 }],
+    }
+    expect(() => decode(bad)).toThrow()
+    expect(() => SessionPrompt.PromptInput.zod.parse(bad)).toThrow()
+  })
+
+  test("CommandInput round-trips core fields", () => {
+    const decode = decodeUnknown(SessionPrompt.CommandInput)
+    const expected = {
+      sessionID,
+      arguments: "--flag",
+      command: "deploy",
+    }
+    const input: unknown = expected
+    expect(decode(input)).toEqual(expected)
+    expect(SessionPrompt.CommandInput.zod.parse(input)).toEqual(expected)
+  })
+})