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

This commit is contained in:
Kit Langton
2026-04-23 11:30:02 -04:00
committed by GitHub
parent bbf67d0fff
commit 0517ab4695
18 changed files with 812 additions and 442 deletions
+12 -9
View File
@@ -159,6 +159,7 @@ Schema at source.
These are the highest-priority next targets. Each is a small, self-contained These are the highest-priority next targets. Each is a small, self-contained
schema module with a clear domain. schema module with a clear domain.
- [x] `src/account/schema.ts`
- [x] `src/control-plane/schema.ts` - [x] `src/control-plane/schema.ts`
- [x] `src/permission/schema.ts` - [x] `src/permission/schema.ts`
- [x] `src/project/schema.ts` - [x] `src/project/schema.ts`
@@ -166,8 +167,10 @@ schema module with a clear domain.
- [x] `src/pty/schema.ts` - [x] `src/pty/schema.ts`
- [x] `src/question/schema.ts` - [x] `src/question/schema.ts`
- [x] `src/session/schema.ts` - [x] `src/session/schema.ts`
- [x] `src/storage/schema.ts`
- [x] `src/sync/schema.ts` - [x] `src/sync/schema.ts`
- [x] `src/tool/schema.ts` - [x] `src/tool/schema.ts`
- [x] `src/util/schema.ts`
### Session domain ### 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 - promote repeated opaque strings and timestamp numbers into branded/newtype
leaf schemas where that adds domain value without changing the wire format leaf schemas where that adds domain value without changing the wire format
- [ ] `src/session/compaction.ts` - [x] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts` - [x] `src/session/message-v2.ts`
- [ ] `src/session/message.ts` - [x] `src/session/message.ts`
- [ ] `src/session/prompt.ts` - [x] `src/session/prompt.ts`
- [ ] `src/session/revert.ts` - [x] `src/session/revert.ts`
- [ ] `src/session/session.ts` - [x] `src/session/session.ts`
- [ ] `src/session/status.ts` - [x] `src/session/status.ts`
- [ ] `src/session/summary.ts` - [x] `src/session/summary.ts`
- [ ] `src/session/todo.ts` - [x] `src/session/todo.ts`
### Provider domain ### Provider domain
+2 -2
View File
@@ -372,7 +372,7 @@ export class Agent implements ACPAgent {
} }
if (part.tool === "todowrite") { 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) { if (parsedTodos.success) {
await this.connection await this.connection
.sessionUpdate({ .sessionUpdate({
@@ -901,7 +901,7 @@ export class Agent implements ACPAgent {
} }
if (part.tool === "todowrite") { 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) { if (parsedTodos.success) {
await this.connection await this.connection
.sessionUpdate({ .sessionUpdate({
+3 -2
View File
@@ -11,6 +11,7 @@ import { ShareNext } from "../../share"
import { EOL } from "os" import { EOL } from "os"
import { Filesystem } from "../../util" import { Filesystem } from "../../util"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Schema } from "effect"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData = export type ShareData =
@@ -154,10 +155,10 @@ export const ImportCommand = cmd({
return return
} }
const info = Session.Info.parse({ const info = Schema.decodeUnknownSync(Session.Info)({
...exportData.info, ...exportData.info,
projectID: Instance.project.id, projectID: Instance.project.id,
}) }) as Session.Info
const row = Session.toRow(info) const row = Session.toRow(info)
Database.use((db) => Database.use((db) =>
db db
@@ -335,7 +335,7 @@ export const ExperimentalRoutes = lazy(() =>
description: "List of sessions", description: "List of sessions",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.GlobalInfo.array()), schema: resolver(Session.GlobalInfo.zod.array()),
}, },
}, },
}, },
@@ -23,6 +23,7 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { zodObject } from "@/util/effect-zod"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import { jsonRequest, runRequest } from "./trace" import { jsonRequest, runRequest } from "./trace"
@@ -42,7 +43,7 @@ export const SessionRoutes = lazy(() =>
description: "List of sessions", description: "List of sessions",
content: { content: {
"application/json": { "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", description: "Get session status",
content: { content: {
"application/json": { "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", description: "Get session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
@@ -122,7 +123,7 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: Session.GetInput, sessionID: Session.GetInput.zod,
}), }),
), ),
async (c) => { async (c) => {
@@ -145,7 +146,7 @@ export const SessionRoutes = lazy(() =>
description: "List of children", description: "List of children",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info.array()), schema: resolver(Session.Info.zod.array()),
}, },
}, },
}, },
@@ -155,7 +156,7 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: Session.ChildrenInput, sessionID: Session.ChildrenInput.zod,
}), }),
), ),
async (c) => { async (c) => {
@@ -177,7 +178,7 @@ export const SessionRoutes = lazy(() =>
description: "Todo list", description: "Todo list",
content: { content: {
"application/json": { "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", description: "Successfully created session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
}, },
}), }),
validator("json", Session.CreateInput), validator("json", Session.CreateInput.zod),
async (c) => async (c) =>
jsonRequest("SessionRoutes.create", c, function* () { jsonRequest("SessionRoutes.create", c, function* () {
const body = c.req.valid("json") ?? {} const body = c.req.valid("json") ?? {}
@@ -245,7 +246,7 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: Session.RemoveInput, sessionID: Session.RemoveInput.zod,
}), }),
), ),
async (c) => async (c) =>
@@ -267,7 +268,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully updated session", description: "Successfully updated session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
@@ -375,7 +376,7 @@ export const SessionRoutes = lazy(() =>
description: "200", description: "200",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
@@ -384,14 +385,14 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ 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) => async (c) =>
jsonRequest("SessionRoutes.fork", c, function* () { jsonRequest("SessionRoutes.fork", c, function* () {
const sessionID = c.req.valid("param").sessionID 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 const svc = yield* Session.Service
return yield* svc.fork({ ...body, sessionID }) return yield* svc.fork({ ...body, sessionID })
}), }),
@@ -438,7 +439,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully shared session", description: "Successfully shared session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
@@ -480,18 +481,13 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: SessionSummary.DiffInput.shape.sessionID, sessionID: SessionID.zod,
}),
),
validator(
"query",
z.object({
messageID: SessionSummary.DiffInput.shape.messageID,
}), }),
), ),
validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })),
async (c) => async (c) =>
jsonRequest("SessionRoutes.diff", c, function* () { 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 params = c.req.valid("param")
const summary = yield* SessionSummary.Service const summary = yield* SessionSummary.Service
return yield* summary.diff({ return yield* summary.diff({
@@ -511,7 +507,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully unshared session", description: "Successfully unshared session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
@@ -872,7 +868,7 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
async (c) => { async (c) => {
c.status(200) c.status(200)
c.header("Content-Type", "application/json") c.header("Content-Type", "application/json")
@@ -910,7 +906,7 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
@@ -960,11 +956,11 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })),
async (c) => async (c) =>
jsonRequest("SessionRoutes.command", c, function* () { jsonRequest("SessionRoutes.command", c, function* () {
const sessionID = c.req.valid("param").sessionID 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 const svc = yield* SessionPrompt.Service
return yield* svc.command({ ...body, sessionID }) return yield* svc.command({ ...body, sessionID })
}), }),
@@ -993,11 +989,11 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })),
async (c) => async (c) =>
jsonRequest("SessionRoutes.shell", c, function* () { jsonRequest("SessionRoutes.shell", c, function* () {
const sessionID = c.req.valid("param").sessionID 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 const svc = yield* SessionPrompt.Service
return yield* svc.shell({ ...body, sessionID }) return yield* svc.shell({ ...body, sessionID })
}), }),
@@ -1013,7 +1009,7 @@ export const SessionRoutes = lazy(() =>
description: "Updated session", description: "Updated session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
@@ -1026,16 +1022,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID 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* () { return jsonRequest("SessionRoutes.revert", c, function* () {
const svc = yield* SessionRevert.Service const svc = yield* SessionRevert.Service
return yield* svc.revert({ return yield* svc.revert({ sessionID, ...body })
sessionID,
...c.req.valid("json"),
})
}) })
}, },
) )
@@ -1050,7 +1044,7 @@ export const SessionRoutes = lazy(() =>
description: "Updated session", description: "Updated session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Session.Info), schema: resolver(Session.Info.zod),
}, },
}, },
}, },
+177 -176
View File
@@ -1,191 +1,192 @@
import z from "zod" import { Schema } from "effect"
import { SessionID } from "./schema" import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { NamedError } from "@opencode-ai/shared/util/error" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { namedSchemaError } from "@/util/named-schema-error"
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AuthError = NamedError.create( export const AuthError = namedSchemaError("ProviderAuthError", {
"ProviderAuthError", providerID: Schema.String,
z.object({ message: Schema.String,
providerID: z.string(),
message: z.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 const AuthErrorEffect = Schema.Struct({
.object({ name: Schema.Literal("ProviderAuthError"),
type: z.literal("text"), data: Schema.Struct({
text: z.string(), providerID: Schema.String,
}) message: Schema.String,
.meta({ }),
ref: "TextPart", })
})
export type TextPart = z.infer<typeof TextPart>
export const ReasoningPart = z const OutputLengthErrorEffect = Schema.Struct({
.object({ name: Schema.Literal("MessageOutputLengthError"),
type: z.literal("reasoning"), data: Schema.Struct({}),
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 const UnknownErrorEffect = Schema.Struct({
.object({ name: Schema.Literal("UnknownError"),
type: z.literal("tool-invocation"), data: Schema.Struct({
toolInvocation: ToolInvocation, message: Schema.String,
}) }),
.meta({ })
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
export const SourceUrlPart = z export const ToolCall = Schema.Struct({
.object({ state: Schema.Literal("call"),
type: z.literal("source-url"), step: Schema.optional(Schema.Number),
sourceId: z.string(), toolCallId: Schema.String,
url: z.string(), toolName: Schema.String,
title: z.string().optional(), args: Schema.Unknown,
providerMetadata: z.record(z.string(), z.any()).optional(), })
}) .annotate({ identifier: "ToolCall" })
.meta({ .pipe(withStatics((s) => ({ zod: zod(s) })))
ref: "SourceUrlPart", export type ToolCall = Schema.Schema.Type<typeof ToolCall>
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
export const FilePart = z export const ToolPartialCall = Schema.Struct({
.object({ state: Schema.Literal("partial-call"),
type: z.literal("file"), step: Schema.optional(Schema.Number),
mediaType: z.string(), toolCallId: Schema.String,
filename: z.string().optional(), toolName: Schema.String,
url: z.string(), args: Schema.Unknown,
}) })
.meta({ .annotate({ identifier: "ToolPartialCall" })
ref: "FilePart", .pipe(withStatics((s) => ({ zod: zod(s) })))
}) export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
export type FilePart = z.infer<typeof FilePart>
export const StepStartPart = z export const ToolResult = Schema.Struct({
.object({ state: Schema.Literal("result"),
type: z.literal("step-start"), step: Schema.optional(Schema.Number),
}) toolCallId: Schema.String,
.meta({ toolName: Schema.String,
ref: "StepStartPart", args: Schema.Unknown,
}) result: Schema.String,
export type StepStartPart = z.infer<typeof StepStartPart> })
.annotate({ identifier: "ToolResult" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const MessagePart = z export const ToolInvocation = Schema.Union([ToolCall, ToolPartialCall, ToolResult])
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart]) .annotate({ identifier: "ToolInvocation", discriminator: "state" })
.meta({ .pipe(withStatics((s) => ({ zod: zod(s) })))
ref: "MessagePart", export type ToolInvocation = Schema.Schema.Type<typeof ToolInvocation>
})
export type MessagePart = z.infer<typeof MessagePart>
export const Info = z export const TextPart = Schema.Struct({
.object({ type: Schema.Literal("text"),
id: z.string(), text: Schema.String,
role: z.enum(["user", "assistant"]), })
parts: z.array(MessagePart), .annotate({ identifier: "TextPart" })
metadata: z .pipe(withStatics((s) => ({ zod: zod(s) })))
.object({ export type TextPart = Schema.Schema.Type<typeof TextPart>
time: z.object({
created: z.number(), export const ReasoningPart = Schema.Struct({
completed: z.number().optional(), 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,
}),
}), }),
error: z [Schema.Record(Schema.String, Schema.Unknown)],
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema]) ),
.optional(), ),
sessionID: SessionID.zod, assistant: Schema.optional(
tool: z.record( Schema.Struct({
z.string(), system: Schema.Array(Schema.String),
z modelID: ModelID,
.object({ providerID: ProviderID,
title: z.string(), path: Schema.Struct({
snapshot: z.string().optional(), cwd: Schema.String,
time: z.object({ root: Schema.String,
start: z.number(), }),
end: z.number(), cost: Schema.Number,
}), summary: Schema.optional(Schema.Boolean),
}) tokens: Schema.Struct({
.catchall(z.any()), input: Schema.Number,
), output: Schema.Number,
assistant: z reasoning: Schema.Number,
.object({ cache: Schema.Struct({
system: z.string().array(), read: Schema.Number,
modelID: ModelID.zod, write: Schema.Number,
providerID: ProviderID.zod, }),
path: z.object({ }),
cwd: z.string(), }),
root: z.string(), ),
}), snapshot: Schema.optional(Schema.String),
cost: z.number(), }).annotate({ identifier: "MessageMetadata" }),
summary: z.boolean().optional(), })
tokens: z.object({ .annotate({ identifier: "Message" })
input: z.number(), .pipe(withStatics((s) => ({ zod: zod(s) })))
output: z.number(), export type Info = Schema.Schema.Type<typeof Info>
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>
export * as Message from "./message" export * as Message from "./message"
+1 -1
View File
@@ -62,7 +62,7 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
export default [ export default [
SyncEvent.project(Session.Event.Created, (db, data) => { 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) => { SyncEvent.project(Session.Event.Updated, (db, data) => {
+74 -75
View File
@@ -43,7 +43,9 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Truncate } from "@/tool" import { Truncate } from "@/tool"
import { decodeDataUrl } from "@/util/data-url" import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util" 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 { EffectLogger } from "@/effect"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { TaskTool, type TaskPromptOps } from "@/tool/task" import { TaskTool, type TaskPromptOps } from "@/tool/task"
@@ -69,7 +71,7 @@ const elog = EffectLogger.create({ service: "session.prompt" })
export interface Interface { export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void> readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> 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 shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts> readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]> 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( const loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.loop")(function* (
"SessionPrompt.loop", input: LoopInput,
)(function* (input: z.infer<typeof LoopInput>) { ) {
return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) 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({ const ModelRef = Schema.Struct({
sessionID: SessionID.zod, providerID: ProviderID,
messageID: MessageID.zod.optional(), modelID: ModelID,
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>,
]),
),
}) })
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 // `z.discriminatedUnion` erases the discriminated members' shapes back to
// `{}` because the derived `.zod` on each input is typed as an opaque // `{}` when walked from the generic `z.ZodType` input. Restore the precise
// `z.ZodType`. Restore the precise `parts` type from the exported Schema // `parts` type from the exported Schema input types so callers see a proper
// input types so callers see a proper tagged union. // tagged union.
type PartInputUnion = type PartInputUnion =
| MessageV2.TextPartInput | MessageV2.TextPartInput
| MessageV2.FilePartInput | MessageV2.FilePartInput
| MessageV2.AgentPartInput | MessageV2.AgentPartInput
| MessageV2.SubtaskPartInput | MessageV2.SubtaskPartInput
export type PromptInput = Omit<z.infer<typeof PromptInput>, "parts"> & { export type PromptInput = Omit<Schema.Schema.Type<typeof PromptInput>, "parts"> & {
parts: PartInputUnion[] parts: PartInputUnion[]
} }
export const LoopInput = z.object({ export class LoopInput extends Schema.Class<LoopInput>("SessionPrompt.LoopInput")({
sessionID: SessionID.zod, sessionID: SessionID,
}) }) {
static readonly zod = zod(this)
}
export const ShellInput = z.object({ export const ShellInput = Schema.Struct({
sessionID: SessionID.zod, sessionID: SessionID,
messageID: MessageID.zod.optional(), messageID: Schema.optional(MessageID),
agent: z.string(), agent: Schema.String,
model: z model: Schema.optional(ModelRef),
.object({ command: Schema.String,
providerID: ProviderID.zod, }).pipe(withStatics((s) => ({ zod: zod(s) })))
modelID: ModelID.zod, export type ShellInput = Schema.Schema.Type<typeof ShellInput>
})
.optional(),
command: z.string(),
})
export type ShellInput = z.infer<typeof ShellInput>
export const CommandInput = z.object({ export const CommandInput = Schema.Struct({
messageID: MessageID.zod.optional(), messageID: Schema.optional(MessageID),
sessionID: SessionID.zod, sessionID: SessionID,
agent: z.string().optional(), agent: Schema.optional(Schema.String),
model: z.string().optional(), model: Schema.optional(Schema.String),
arguments: z.string(), arguments: Schema.String,
command: z.string(), command: Schema.String,
variant: z.string().optional(), variant: Schema.optional(Schema.String),
// Inlined (no `.meta({ ref })`) to keep the original SDK output — the // Inlined (no identifier annotation) to keep the original SDK output — the
// PromptInput call site below references FilePartInput by ref via the // PromptInput call site below references FilePartInput by ref via the
// Schema export in message-v2.ts. // Schema export in message-v2.ts.
parts: z parts: Schema.optional(
.array( Schema.Array(
z.discriminatedUnion("type", [ Schema.Union([
z.object({ Schema.Struct({
id: PartID.zod.optional(), id: Schema.optional(PartID),
type: z.literal("file"), type: Schema.Literal("file"),
mime: z.string(), mime: Schema.String,
filename: z.string().optional(), filename: Schema.optional(Schema.String),
url: z.string(), url: Schema.String,
source: MessageV2.FilePartSource.zod.optional(), source: Schema.optional(MessageV2.FilePartSource),
}), }),
]), ]).annotate({ discriminator: "type" }),
) ),
.optional(), ),
}) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type CommandInput = z.infer<typeof CommandInput> export type CommandInput = Schema.Schema.Type<typeof CommandInput>
/** @internal Exported for testing */ /** @internal Exported for testing */
export function createStructuredOutputTool(input: { export function createStructuredOutputTool(input: {
+9 -8
View File
@@ -1,10 +1,11 @@
import z from "zod" import { Effect, Layer, Context, Schema } from "effect"
import { Effect, Layer, Context } from "effect"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Snapshot } from "../snapshot" import { Snapshot } from "../snapshot"
import { Storage } from "@/storage" import { Storage } from "@/storage"
import { SyncEvent } from "../sync" import { SyncEvent } from "../sync"
import { Log } from "../util" import { Log } from "../util"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as Session from "./session" import * as Session from "./session"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
@@ -13,12 +14,12 @@ import { SessionSummary } from "./summary"
const log = Log.create({ service: "session.revert" }) const log = Log.create({ service: "session.revert" })
export const RevertInput = z.object({ export const RevertInput = Schema.Struct({
sessionID: SessionID.zod, sessionID: SessionID,
messageID: MessageID.zod, messageID: MessageID,
partID: PartID.zod.optional(), partID: Schema.optional(PartID),
}) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type RevertInput = z.infer<typeof RevertInput> export type RevertInput = Schema.Schema.Type<typeof RevertInput>
export interface Interface { export interface Interface {
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info> readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
+104 -89
View File
@@ -27,7 +27,9 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider" import type { Provider } from "@/provider"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" 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" }) const log = Log.create({ service: "session" })
@@ -114,91 +116,104 @@ function getForkedTitle(title: string): string {
return `${title} (fork #1)` return `${title} (fork #1)`
} }
export const Info = z const Summary = Schema.Struct({
.object({ additions: Schema.Number,
id: SessionID.zod, deletions: Schema.Number,
slug: z.string(), files: Schema.Number,
projectID: ProjectID.zod, diffs: Schema.optional(Schema.Array(Snapshot.FileDiff)),
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>
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>
export const GlobalInfo = Info.extend({
project: ProjectInfo.nullable(),
}).meta({
ref: "GlobalSession",
}) })
export type GlobalInfo = z.output<typeof GlobalInfo>
export const CreateInput = z const Share = Schema.Struct({
.object({ url: Schema.String,
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,
}) })
export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
const Time = Schema.Struct({
created: Schema.Number,
updated: Schema.Number,
compacting: Schema.optional(Schema.Number),
archived: Schema.optional(Schema.Number),
})
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,
})
.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 = { export const Event = {
Created: SyncEvent.define({ Created: SyncEvent.define({
@@ -207,7 +222,7 @@ export const Event = {
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
info: Info, info: Info.zod,
}), }),
}), }),
Updated: SyncEvent.define({ Updated: SyncEvent.define({
@@ -216,14 +231,14 @@ export const Event = {
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
info: updateSchema(Info).extend({ info: updateSchema(zodObject(Info)).extend({
share: updateSchema(Info.shape.share.unwrap()).optional(), share: updateSchema(zodObject(Share)).optional(),
time: updateSchema(Info.shape.time).optional(), time: updateSchema(zodObject(Time)).optional(),
}), }),
}), }),
busSchema: z.object({ busSchema: z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
info: Info, info: Info.zod,
}), }),
}), }),
Deleted: SyncEvent.define({ Deleted: SyncEvent.define({
@@ -232,7 +247,7 @@ export const Event = {
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
info: Info, info: Info.zod,
}), }),
}), }),
Diff: BusEvent.define( Diff: BusEvent.define(
+21 -21
View File
@@ -2,35 +2,35 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { SessionID } from "./schema" 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 z from "zod"
export const Info = z export const Info = Schema.Union([
.union([ Schema.Struct({
z.object({ type: Schema.Literal("idle"),
type: z.literal("idle"), }),
}), Schema.Struct({
z.object({ type: Schema.Literal("retry"),
type: z.literal("retry"), attempt: Schema.Number,
attempt: z.number(), message: Schema.String,
message: z.string(), next: Schema.Number,
next: z.number(), }),
}), Schema.Struct({
z.object({ type: Schema.Literal("busy"),
type: z.literal("busy"), }),
}), ])
]) .annotate({ identifier: "SessionStatus" })
.meta({ .pipe(withStatics((s) => ({ zod: zod(s) })))
ref: "SessionStatus", export type Info = Schema.Schema.Type<typeof Info>
})
export type Info = z.infer<typeof Info>
export const Event = { export const Event = {
Status: BusEvent.define( Status: BusEvent.define(
"session.status", "session.status",
z.object({ z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
status: Info, status: Info.zod,
}), }),
), ),
// deprecated // deprecated
+8 -6
View File
@@ -1,8 +1,9 @@
import z from "zod" import { Effect, Layer, Context, Schema } from "effect"
import { Effect, Layer, Context } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage" import { Storage } from "@/storage"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as Session from "./session" import * as Session from "./session"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { SessionID, MessageID } from "./schema" import { SessionID, MessageID } from "./schema"
@@ -155,9 +156,10 @@ export const defaultLayer = Layer.suspend(() =>
), ),
) )
export const DiffInput = z.object({ export const DiffInput = Schema.Struct({
sessionID: SessionID.zod, sessionID: SessionID,
messageID: MessageID.zod.optional(), messageID: Schema.optional(MessageID),
}) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type DiffInput = Schema.Schema.Type<typeof DiffInput>
export * as SessionSummary from "./summary" export * as SessionSummary from "./summary"
+14 -10
View File
@@ -1,26 +1,30 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { SessionID } from "./schema" 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 z from "zod"
import { Database, eq, asc } from "../storage" import { Database, eq, asc } from "../storage"
import { TodoTable } from "./session.sql" import { TodoTable } from "./session.sql"
export const Info = z export const Info = Schema.Struct({
.object({ content: Schema.String.annotate({ description: "Brief description of the task" }),
content: z.string().describe("Brief description of the task"), status: Schema.String.annotate({
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"), description: "Current status of the task: pending, in_progress, completed, cancelled",
priority: z.string().describe("Priority level of the task: high, medium, low"), }),
}) priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
.meta({ ref: "Todo" }) })
export type Info = z.infer<typeof Info> .annotate({ identifier: "Todo" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export const Event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
"todo.updated", "todo.updated",
z.object({ z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
todos: z.array(Info), todos: z.array(Info.zod),
}), }),
), ),
} }
+14 -1
View File
@@ -4,8 +4,21 @@ import * as Tool from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt" import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo" 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({ 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 = { type Metadata = {
+27
View File
@@ -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>> 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 { function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const cached = walkCache.get(ast) const cached = walkCache.get(ast)
if (cached) return cached if (cached) return cached
@@ -18,7 +18,7 @@ const svc = {
create(input?: SessionNs.CreateInput) { create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input))) 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))) return run(SessionNs.Service.use((svc) => svc.setArchived(input)))
}, },
} }
@@ -38,7 +38,7 @@ const svc = {
create(input?: SessionNs.CreateInput) { create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input))) 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))) return run(SessionNs.Service.use((svc) => svc.messages(input)))
}, },
updateMessage<T extends MessageV2.Info>(msg: T) { updateMessage<T extends MessageV2.Info>(msg: T) {
@@ -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)
})
})