Files
opencode/packages/opencode/test/session/schema-decoding.test.ts
2026-05-10 19:58:21 -04:00

345 lines
12 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Session } from "@/session/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 for schemas that still expose Zod statics.
// 3. Clearly-invalid input is rejected by both paths where both exist.
//
// 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 = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K")
const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
const projectID = ProjectID.zod.parse("proj-alpha")
const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("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",
path: "packages/opencode",
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("accepts migrated summary diffs without file details", () => {
const input = {
id: sessionID,
slug: "legacy-diff",
projectID,
directory: "/tmp/proj",
title: "Legacy diff",
version: "0.1.0",
summary: {
additions: 1,
deletions: 0,
files: 1,
diffs: [{ additions: 1, deletions: 0 }],
},
time: { created: 1, updated: 2 },
}
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/action/next", () => {
const input = {
type: "retry" as const,
attempt: 1,
message: "transient",
action: {
reason: "free_tier_limit",
provider: "opencode",
title: "Free limit reached",
message: "Subscribe to OpenCode Go.",
label: "subscribe",
link: "https://opencode.ai/go",
},
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)
})
})