From a9184622660d63f022c2f88ecf358c91505123de Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Apr 2026 10:23:13 -0400 Subject: [PATCH 1/3] refactor(session): migrate session domain to Effect Schema Migrates all session-cluster files from Zod-first definitions to Effect Schema with derived .zod statics, per specs/effect/schema.md. - status.ts, todo.ts, summary.ts, revert.ts, message.ts: Info + input schemas now Schema-first; BusEvent/SyncEvent payloads keep zod per spec. - message-v2.ts, compaction.ts: already substantively Schema-first; remaining zod is only for allowed boundary layers. - session.ts: Info, ProjectInfo, GlobalInfo, CreateInput, ForkInput, Get/Children/Remove/SetTitle/SetArchived/SetPermission/SetRevert/ MessagesInput are Schema-first. Local DeepMutable preserves mutable consumer types. - prompt.ts: PromptInput, LoopInput, ShellInput, CommandInput Schema-first. - Route boundaries updated to consume derived .zod; SDK openapi.json and types.gen.ts are byte-identical. --- packages/opencode/specs/effect/schema.md | 21 +- packages/opencode/src/acp/agent.ts | 4 +- packages/opencode/src/cli/cmd/import.ts | 2 +- .../server/routes/instance/experimental.ts | 2 +- .../src/server/routes/instance/session.ts | 65 ++-- packages/opencode/src/session/message.ts | 359 +++++++++--------- packages/opencode/src/session/prompt.ts | 149 ++++---- packages/opencode/src/session/revert.ts | 17 +- packages/opencode/src/session/session.ts | 221 ++++++----- packages/opencode/src/session/status.ts | 42 +- packages/opencode/src/session/summary.ts | 13 +- packages/opencode/src/session/todo.ts | 24 +- packages/opencode/src/tool/todo.ts | 10 +- .../test/server/global-session-list.test.ts | 2 +- .../opencode/test/session/compaction.test.ts | 2 +- 15 files changed, 493 insertions(+), 440 deletions(-) diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 9ff6859cee1f..3f2c3b4c963c 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/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 diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f12328153b6e..672b93f6ceb7 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/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({ diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 309ec6d95096..d89575c88a4d 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -154,7 +154,7 @@ export const ImportCommand = cmd({ return } - const info = Session.Info.parse({ + const info = Session.Info.zod.parse({ ...exportData.info, projectID: Instance.project.id, }) diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 9c8649498708..93a5d98c94f7 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/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()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 8d03024260da..f33aaf973166 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -42,7 +42,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 +87,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 +112,7 @@ export const SessionRoutes = lazy(() => description: "Get session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -122,7 +122,7 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.GetInput, + sessionID: Session.GetInput.zod, }), ), async (c) => { @@ -145,7 +145,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 +155,7 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.ChildrenInput, + sessionID: Session.ChildrenInput.zod, }), ), async (c) => { @@ -177,7 +177,7 @@ export const SessionRoutes = lazy(() => description: "Todo list", content: { "application/json": { - schema: resolver(Todo.Info.array()), + schema: resolver(Todo.Info.zod.array()), }, }, }, @@ -210,13 +210,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 +245,7 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.RemoveInput, + sessionID: Session.RemoveInput.zod, }), ), async (c) => @@ -267,7 +267,7 @@ export const SessionRoutes = lazy(() => description: "Successfully updated session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -375,7 +375,7 @@ export const SessionRoutes = lazy(() => description: "200", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -384,10 +384,15 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.ForkInput.shape.sessionID, + sessionID: SessionID.zod, + }), + ), + validator( + "json", + z.object({ + messageID: MessageID.zod.optional(), }), ), - validator("json", Session.ForkInput.omit({ sessionID: true })), async (c) => jsonRequest("SessionRoutes.fork", c, function* () { const sessionID = c.req.valid("param").sessionID @@ -438,7 +443,7 @@ export const SessionRoutes = lazy(() => description: "Successfully shared session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -480,13 +485,13 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: SessionSummary.DiffInput.shape.sessionID, + sessionID: SessionID.zod, }), ), validator( "query", z.object({ - messageID: SessionSummary.DiffInput.shape.messageID, + messageID: MessageID.zod.optional(), }), ), async (c) => @@ -511,7 +516,7 @@ export const SessionRoutes = lazy(() => description: "Successfully unshared session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -872,7 +877,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + validator("json", (SessionPrompt.PromptInput.zod as unknown as z.ZodObject).omit({ sessionID: true })), async (c) => { c.status(200) c.header("Content-Type", "application/json") @@ -910,7 +915,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + validator("json", (SessionPrompt.PromptInput.zod as unknown as z.ZodObject).omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") @@ -960,11 +965,11 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), + validator("json", (SessionPrompt.CommandInput.zod as unknown as z.ZodObject).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 const svc = yield* SessionPrompt.Service return yield* svc.command({ ...body, sessionID }) }), @@ -993,11 +998,11 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), + validator("json", (SessionPrompt.ShellInput.zod as unknown as z.ZodObject).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 const svc = yield* SessionPrompt.Service return yield* svc.shell({ ...body, sessionID }) }), @@ -1013,7 +1018,7 @@ export const SessionRoutes = lazy(() => description: "Updated session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -1026,7 +1031,13 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), + validator( + "json", + z.object({ + messageID: MessageID.zod, + partID: PartID.zod.optional(), + }), + ), async (c) => { const sessionID = c.req.valid("param").sessionID log.info("revert", c.req.valid("json")) @@ -1050,7 +1061,7 @@ export const SessionRoutes = lazy(() => description: "Updated session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index ced04b8e9dbe..9c6b4e83b448 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -1,191 +1,182 @@ -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, }), -) - -export const ToolCall = z - .object({ - state: z.literal("call"), - step: z.number().optional(), - toolCallId: z.string(), - toolName: z.string(), - args: z.custom>(), - }) - .meta({ - ref: "ToolCall", - }) -export type ToolCall = z.infer - -export const ToolPartialCall = z - .object({ - state: z.literal("partial-call"), - step: z.number().optional(), - toolCallId: z.string(), - toolName: z.string(), - args: z.custom>(), - }) - .meta({ - ref: "ToolPartialCall", - }) -export type ToolPartialCall = z.infer - -export const ToolResult = z - .object({ - state: z.literal("result"), - step: z.number().optional(), - toolCallId: z.string(), - toolName: z.string(), - args: z.custom>(), - result: z.string(), - }) - .meta({ - ref: "ToolResult", - }) -export type ToolResult = z.infer - -export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({ - ref: "ToolInvocation", }) -export type ToolInvocation = z.infer - -export const TextPart = z - .object({ - type: z.literal("text"), - text: z.string(), - }) - .meta({ - ref: "TextPart", - }) -export type TextPart = z.infer - -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 - -export const ToolInvocationPart = z - .object({ - type: z.literal("tool-invocation"), - toolInvocation: ToolInvocation, - }) - .meta({ - ref: "ToolInvocationPart", - }) -export type ToolInvocationPart = z.infer - -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 - -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 - -export const StepStartPart = z - .object({ - type: z.literal("step-start"), - }) - .meta({ - ref: "StepStartPart", - }) -export type StepStartPart = z.infer - -export const MessagePart = z - .discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart]) - .meta({ - ref: "MessagePart", - }) -export type MessagePart = z.infer - -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(), + +const OutputLengthErrorEffect = Schema.Struct({ + name: Schema.Literal("MessageOutputLengthError"), + data: Schema.Struct({}), +}) + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +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, OutputLengthErrorEffect])), + sessionID: SessionID, + tool: Schema.Record( + Schema.String, + Schema.Struct({ + title: Schema.String, + snapshot: Schema.optional(Schema.String), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.Number, }), - 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 + }), + ), + assistant: Schema.optional( + Schema.Struct({ + system: Schema.Array(Schema.String), + modelID: ModelID, + providerID: ProviderID, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + 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 export * as Message from "./message" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 508c72cc8fd9..0a241e8ccc8b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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 readonly prompt: (input: PromptInput) => Effect.Effect - readonly loop: (input: z.infer) => Effect.Effect + readonly loop: (input: Schema.Schema.Type) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect @@ -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) => Effect.Effect = Effect.fn( + const loop: (input: Schema.Schema.Type) => Effect.Effect = Effect.fn( "SessionPrompt.loop", - )(function* (input: z.infer) { + )(function* (input: Schema.Schema.Type) { return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) }) @@ -1701,91 +1703,86 @@ 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, - MessageV2.FilePartInput.zod as unknown as z.ZodObject, - MessageV2.AgentPartInput.zod as unknown as z.ZodObject, - MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject, - ]), - ), +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, "parts"> & { +export type PromptInput = Omit, "parts"> & { parts: PartInputUnion[] } -export const LoopInput = z.object({ - sessionID: SessionID.zod, -}) - -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 - -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 LoopInput = Schema.Struct({ + sessionID: SessionID, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) + +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 + +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 + ]).annotate({ discriminator: "type" }), + ), + ), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CommandInput = Schema.Schema.Type /** @internal Exported for testing */ export function createStructuredOutputTool(input: { diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index c7e5220f12bd..e1db26510d71 100644 --- a/packages/opencode/src/session/revert.ts +++ b/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 +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 export interface Interface { readonly revert: (input: RevertInput) => Effect.Effect diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a7607798ba40..6b9b29cecf93 100644 --- a/packages/opencode/src/session/session.ts +++ b/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 } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -80,7 +82,7 @@ export function fromRow(row: SessionRow): Info { } } -export function toRow(info: Info) { +export function toRow(info: Schema.Schema.Type) { return { id: info.id, project_id: info.projectID, @@ -94,9 +96,9 @@ export function toRow(info: Info) { summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs, - revert: info.revert ?? null, - permission: info.permission, + summary_diffs: info.summary?.diffs as Snapshot.FileDiff[] | undefined, + revert: (info.revert ?? null) as Info["revert"] | null, + permission: info.permission as Info["permission"], time_created: info.time.created, time_updated: info.time.updated, time_compacting: info.time.compacting, @@ -114,91 +116,126 @@ 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 +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 +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 - -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 - -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), +}) + +// Mirror effect-smol's Types.DeepMutable. Used to strip readonly from +// Schema-derived types so consumer code can continue mutating Info fields +// (e.g. session.revert.snapshot = ...) the way the original zod-derived +// types allowed. See specs/effect/schema.md "Local DeepMutable". +// +// The primitive guard preserves branded scalars like `string & Brand<"SessionID">`, +// which extend `object` via the brand intersection and would otherwise get +// walked into the mapped-type branch and explode to the prototype methods. +type DeepMutable = T extends string | number | boolean | bigint | symbol | null | undefined + ? T + : T extends readonly [unknown, ...unknown[]] + ? { -readonly [K in keyof T]: DeepMutable } + : T extends readonly (infer U)[] + ? DeepMutable[] + : T extends object + ? { -readonly [K in keyof T]: DeepMutable } + : T + +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 = DeepMutable> + +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 = DeepMutable> + +export const GlobalInfo = Schema.Struct({ + ...Info.fields, + project: Schema.NullOr(ProjectInfo), +}) + .annotate({ identifier: "GlobalSession" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type GlobalInfo = DeepMutable> + +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 = DeepMutable> + +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) }))) + +const InfoZod = Info.zod as unknown as z.ZodObject +const ShareZod = zod(Share) as unknown as z.ZodObject +const TimeZod = zod(Time) as unknown as z.ZodObject export const Event = { Created: SyncEvent.define({ @@ -207,7 +244,7 @@ export const Event = { aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, - info: Info, + info: Info.zod, }), }), Updated: SyncEvent.define({ @@ -216,14 +253,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(InfoZod).extend({ + share: updateSchema(ShareZod).optional(), + time: updateSchema(TimeZod).optional(), }), }), busSchema: z.object({ sessionID: SessionID.zod, - info: Info, + info: Info.zod, }), }), Deleted: SyncEvent.define({ @@ -232,7 +269,7 @@ export const Event = { aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, - info: Info, + info: Info.zod, }), }), Diff: BusEvent.define( diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 7f46c70a8a28..b9b9fd7e7496 100644 --- a/packages/opencode/src/session/status.ts +++ b/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 +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 export const Event = { Status: BusEvent.define( "session.status", z.object({ sessionID: SessionID.zod, - status: Info, + status: Info.zod, }), ), // deprecated diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 70b3102f6e89..6c2f1ccce169 100644 --- a/packages/opencode/src/session/summary.ts +++ b/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,9 @@ 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 * as SessionSummary from "./summary" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 4840f86a3d90..257b586ed7e0 100644 --- a/packages/opencode/src/session/todo.ts +++ b/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 +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 export const Event = { Updated: BusEvent.define( "todo.updated", z.object({ sessionID: SessionID.zod, - todos: z.array(Info), + todos: z.array(Info.zod), }), ), } diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 5090f17a7c27..f202203fb956 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -5,7 +5,15 @@ import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" 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 = { diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index d0f71b8fd374..03b1a0346aee 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/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) { + setArchived(input: z.output) { return run(SessionNs.Service.use((svc) => svc.setArchived(input))) }, } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 037613d469af..4fe9c1551136 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/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) { + messages(input: z.output) { return run(SessionNs.Service.use((svc) => svc.messages(input))) }, updateMessage(msg: T) { From 34f13953f47411f9422a5e348829632d30e81c60 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Apr 2026 10:55:05 -0400 Subject: [PATCH 2/3] simplify session migration per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session.ts: use Types.DeepMutable from effect instead of a local copy, matching message-v2.ts / project.ts / provider.ts convention. - session.ts:toRow: take Info (the DeepMutable alias) so readonly->mutable casts on summary_diffs / revert / permission disappear; import.ts and projectors.ts round-trip readonly data with a single `as Session.Info` at the call site. - effect-zod: add `zodObject(schema)` helper so consumers stop writing `X.zod as unknown as z.ZodObject` everywhere; used by session.ts Event.Updated and by the four omit-sessionID routes. - routes/instance/session.ts: restore `.omit({ sessionID: true })` on the canonical schema for fork / diff / revert (was inlined, drifted from source of truth); body accessors carry one `as Omit<..., "sessionID">` cast each — the cast is unavoidable given zod's optional-field inference gap through Schema.optional, and will disappear once we move to @effect/platform HttpApi (see specs/effect/http-api.md). - summary.ts: export DiffInput type alias so route consumers don't need to re-infer it from the schema. - tool/todo.ts: keep the 3-field zod object inline; deriving from Todo.Info.zod erases field types because zodObject returns ZodObject, which would break Tool.define's execute() inference and cascade to tui consumers. --- packages/opencode/src/cli/cmd/import.ts | 2 +- .../src/server/routes/instance/session.ts | 43 +++++------------ packages/opencode/src/session/projectors.ts | 2 +- packages/opencode/src/session/session.ts | 48 +++++-------------- packages/opencode/src/session/summary.ts | 1 + packages/opencode/src/tool/todo.ts | 5 ++ packages/opencode/src/util/effect-zod.ts | 27 +++++++++++ 7 files changed, 61 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index d89575c88a4d..cca5124b9132 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -157,7 +157,7 @@ export const ImportCommand = cmd({ const info = Session.Info.zod.parse({ ...exportData.info, projectID: Instance.project.id, - }) + }) as Session.Info const row = Session.toRow(info) Database.use((db) => db diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index f33aaf973166..4f4f8ed86e7f 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/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" @@ -387,16 +388,11 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator( - "json", - z.object({ - messageID: MessageID.zod.optional(), - }), - ), + 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 }) }), @@ -488,15 +484,10 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator( - "query", - z.object({ - messageID: MessageID.zod.optional(), - }), - ), + 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 const params = c.req.valid("param") const summary = yield* SessionSummary.Service return yield* summary.diff({ @@ -877,7 +868,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", (SessionPrompt.PromptInput.zod as unknown as z.ZodObject).omit({ sessionID: true })), + validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), async (c) => { c.status(200) c.header("Content-Type", "application/json") @@ -915,7 +906,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", (SessionPrompt.PromptInput.zod as unknown as z.ZodObject).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") @@ -965,7 +956,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", (SessionPrompt.CommandInput.zod as unknown as z.ZodObject).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 @@ -998,7 +989,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", (SessionPrompt.ShellInput.zod as unknown as z.ZodObject).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 @@ -1031,22 +1022,14 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator( - "json", - z.object({ - messageID: MessageID.zod, - partID: PartID.zod.optional(), - }), - ), + 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 + 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 }) }) }, ) diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index fb8354dda11f..b55f9dcc7a55 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -62,7 +62,7 @@ export function toPartialRow(info: DeepPartial) { 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) => { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 6b9b29cecf93..d2bdbccb7dbc 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -27,8 +27,8 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider" import { Permission } from "@/permission" import { Global } from "@/global" -import { Effect, Layer, Option, Context, Schema } from "effect" -import { zod } from "@/util/effect-zod" +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" }) @@ -82,7 +82,7 @@ export function fromRow(row: SessionRow): Info { } } -export function toRow(info: Schema.Schema.Type) { +export function toRow(info: Info) { return { id: info.id, project_id: info.projectID, @@ -96,9 +96,9 @@ export function toRow(info: Schema.Schema.Type) { summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs as Snapshot.FileDiff[] | undefined, - revert: (info.revert ?? null) as Info["revert"] | null, - permission: info.permission as Info["permission"], + summary_diffs: info.summary?.diffs, + revert: info.revert ?? null, + permission: info.permission, time_created: info.time.created, time_updated: info.time.updated, time_compacting: info.time.compacting, @@ -141,24 +141,6 @@ const Revert = Schema.Struct({ diff: Schema.optional(Schema.String), }) -// Mirror effect-smol's Types.DeepMutable. Used to strip readonly from -// Schema-derived types so consumer code can continue mutating Info fields -// (e.g. session.revert.snapshot = ...) the way the original zod-derived -// types allowed. See specs/effect/schema.md "Local DeepMutable". -// -// The primitive guard preserves branded scalars like `string & Brand<"SessionID">`, -// which extend `object` via the brand intersection and would otherwise get -// walked into the mapped-type branch and explode to the prototype methods. -type DeepMutable = T extends string | number | boolean | bigint | symbol | null | undefined - ? T - : T extends readonly [unknown, ...unknown[]] - ? { -readonly [K in keyof T]: DeepMutable } - : T extends readonly (infer U)[] - ? DeepMutable[] - : T extends object - ? { -readonly [K in keyof T]: DeepMutable } - : T - export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -176,7 +158,7 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "Session" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = DeepMutable> +export type Info = Types.DeepMutable> export const ProjectInfo = Schema.Struct({ id: ProjectID, @@ -185,7 +167,7 @@ export const ProjectInfo = Schema.Struct({ }) .annotate({ identifier: "ProjectSummary" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type ProjectInfo = DeepMutable> +export type ProjectInfo = Types.DeepMutable> export const GlobalInfo = Schema.Struct({ ...Info.fields, @@ -193,7 +175,7 @@ export const GlobalInfo = Schema.Struct({ }) .annotate({ identifier: "GlobalSession" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type GlobalInfo = DeepMutable> +export type GlobalInfo = Types.DeepMutable> export const CreateInput = Schema.optional( Schema.Struct({ @@ -203,7 +185,7 @@ export const CreateInput = Schema.optional( workspaceID: Schema.optional(WorkspaceID), }), ).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type CreateInput = DeepMutable> +export type CreateInput = Types.DeepMutable> export const ForkInput = Schema.Struct({ sessionID: SessionID, @@ -233,10 +215,6 @@ export const MessagesInput = Schema.Struct({ limit: Schema.optional(Schema.Number), }).pipe(withStatics((s) => ({ zod: zod(s) }))) -const InfoZod = Info.zod as unknown as z.ZodObject -const ShareZod = zod(Share) as unknown as z.ZodObject -const TimeZod = zod(Time) as unknown as z.ZodObject - export const Event = { Created: SyncEvent.define({ type: "session.created", @@ -253,9 +231,9 @@ export const Event = { aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, - info: updateSchema(InfoZod).extend({ - share: updateSchema(ShareZod).optional(), - time: updateSchema(TimeZod).optional(), + info: updateSchema(zodObject(Info)).extend({ + share: updateSchema(zodObject(Share)).optional(), + time: updateSchema(zodObject(Time)).optional(), }), }), busSchema: z.object({ diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 6c2f1ccce169..04a24d2c2be8 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -160,5 +160,6 @@ export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), }).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type DiffInput = Schema.Schema.Type export * as SessionSummary from "./summary" diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index f202203fb956..c08fb0411991 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -4,6 +4,11 @@ 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 — 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( diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index f6d2c5e5c04f..edbbf4d542c9 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -22,6 +22,33 @@ export function zod(schema: S): z.ZodType> } +/** + * Derive a Zod value from an Effect Schema (or a Schema-backed export with a + * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, + * `.omit`, `.extend`, and friends are accessible. + * + * The `zod()` walker returns `z.ZodType` 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` 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` + * 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(schema: S): z.ZodObject { + const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) + return derived as unknown as z.ZodObject +} + +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 From 490a73b0e31a648dd672f26b789cb7abd2f8db90 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Apr 2026 11:24:39 -0400 Subject: [PATCH 3/3] test(session): cover schema decoding parity Use Effect Schema decoding for session import, restore legacy message metadata compatibility, and add focused decode-vs-zod coverage for the migrated session-domain schemas. Also make LoopInput a Schema.Class so prompt loop uses its nominal type directly. --- packages/opencode/src/cli/cmd/import.ts | 3 +- packages/opencode/src/session/message.ts | 26 +- packages/opencode/src/session/prompt.ts | 14 +- .../test/session/schema-decoding.test.ts | 310 ++++++++++++++++++ 4 files changed, 338 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/test/session/schema-decoding.test.ts diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index cca5124b9132..e52120f1af79 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/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,7 +155,7 @@ export const ImportCommand = cmd({ return } - const info = Session.Info.zod.parse({ + const info = Schema.decodeUnknownSync(Session.Info)({ ...exportData.info, projectID: Instance.project.id, }) as Session.Info diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 9c6b4e83b448..b1b245343162 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -24,6 +24,13 @@ const OutputLengthErrorEffect = Schema.Struct({ data: Schema.Struct({}), }) +const UnknownErrorEffect = Schema.Struct({ + name: Schema.Literal("UnknownError"), + data: Schema.Struct({ + message: Schema.String, + }), +}) + export const ToolCall = Schema.Struct({ state: Schema.Literal("call"), step: Schema.optional(Schema.Number), @@ -137,18 +144,21 @@ export const Info = Schema.Struct({ created: Schema.Number, completed: Schema.optional(Schema.Number), }), - error: Schema.optional(Schema.Union([AuthErrorEffect, OutputLengthErrorEffect])), + error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), sessionID: SessionID, tool: Schema.Record( Schema.String, - Schema.Struct({ - title: Schema.String, - snapshot: Schema.optional(Schema.String), - time: Schema.Struct({ - start: Schema.Number, - end: Schema.Number, + 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({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0a241e8ccc8b..0f48eb64ec76 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -71,7 +71,7 @@ const elog = EffectLogger.create({ service: "session.prompt" }) export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly loop: (input: Schema.Schema.Type) => Effect.Effect + readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect @@ -1534,9 +1534,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) - const loop: (input: Schema.Schema.Type) => Effect.Effect = Effect.fn( - "SessionPrompt.loop", - )(function* (input: Schema.Schema.Type) { + const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( + input: LoopInput, + ) { return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) }) @@ -1743,9 +1743,11 @@ export type PromptInput = Omit, "parts"> parts: PartInputUnion[] } -export const LoopInput = Schema.Struct({ +export class LoopInput extends Schema.Class("SessionPrompt.LoopInput")({ sessionID: SessionID, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) { + static readonly zod = zod(this) +} export const ShellInput = Schema.Struct({ sessionID: SessionID, diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts new file mode 100644 index 000000000000..5894b2615414 --- /dev/null +++ b/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(schema: S) { + const decode = Schema.decodeUnknownSync(schema as any) + return (input: unknown): Schema.Schema.Type => decode(input) as Schema.Schema.Type +} + +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) + }) +})