瀏覽代碼

feat(id): brand SessionID through Drizzle and Zod schemas (#16953)

Kit Langton 5 月之前
父節點
當前提交
cb67465675
共有 44 個文件被更改,包括 226 次插入158 次删除
  1. 4 3
      packages/opencode/src/cli/cmd/export.ts
  2. 2 1
      packages/opencode/src/cli/cmd/github.ts
  3. 9 3
      packages/opencode/src/cli/cmd/import.ts
  4. 4 2
      packages/opencode/src/cli/cmd/session.ts
  5. 2 1
      packages/opencode/src/cli/cmd/tui/event.ts
  6. 2 1
      packages/opencode/src/command/index.ts
  7. 3 2
      packages/opencode/src/permission/index.ts
  8. 3 2
      packages/opencode/src/permission/next.ts
  9. 5 4
      packages/opencode/src/question/index.ts
  10. 19 18
      packages/opencode/src/server/routes/session.ts
  11. 5 4
      packages/opencode/src/session/compaction.ts
  12. 28 27
      packages/opencode/src/session/index.ts
  13. 8 7
      packages/opencode/src/session/message-v2.ts
  14. 2 1
      packages/opencode/src/session/message.ts
  15. 2 1
      packages/opencode/src/session/processor.ts
  16. 8 7
      packages/opencode/src/session/prompt.ts
  17. 3 2
      packages/opencode/src/session/revert.ts
  18. 17 0
      packages/opencode/src/session/schema.ts
  19. 6 3
      packages/opencode/src/session/session.sql.ts
  20. 5 4
      packages/opencode/src/session/status.ts
  21. 4 3
      packages/opencode/src/session/summary.ts
  22. 4 3
      packages/opencode/src/session/todo.ts
  23. 6 5
      packages/opencode/src/share/share-next.ts
  24. 2 1
      packages/opencode/src/tool/plan.ts
  25. 2 1
      packages/opencode/src/tool/task.ts
  26. 2 1
      packages/opencode/src/tool/tool.ts
  27. 7 6
      packages/opencode/test/cli/github-action.test.ts
  28. 2 1
      packages/opencode/test/memory/abort-leak.test.ts
  29. 12 11
      packages/opencode/test/permission/next.test.ts
  30. 10 9
      packages/opencode/test/question/question.test.ts
  31. 5 4
      packages/opencode/test/session/llm.test.ts
  32. 2 1
      packages/opencode/test/session/message-v2.test.ts
  33. 4 3
      packages/opencode/test/session/structured-output.test.ts
  34. 7 6
      packages/opencode/test/storage/json-migration.test.ts
  35. 2 1
      packages/opencode/test/tool/apply_patch.test.ts
  36. 2 1
      packages/opencode/test/tool/bash.test.ts
  37. 2 1
      packages/opencode/test/tool/edit.test.ts
  38. 2 1
      packages/opencode/test/tool/external-directory.test.ts
  39. 2 1
      packages/opencode/test/tool/grep.test.ts
  40. 2 1
      packages/opencode/test/tool/question.test.ts
  41. 2 1
      packages/opencode/test/tool/read.test.ts
  42. 2 1
      packages/opencode/test/tool/skill.test.ts
  43. 2 1
      packages/opencode/test/tool/webfetch.test.ts
  44. 2 1
      packages/opencode/test/tool/write.test.ts

+ 4 - 3
packages/opencode/src/cli/cmd/export.ts

@@ -1,5 +1,6 @@
 import type { Argv } from "yargs"
 import { Session } from "../../session"
+import { SessionID } from "../../session/schema"
 import { cmd } from "./cmd"
 import { bootstrap } from "../bootstrap"
 import { UI } from "../ui"
@@ -17,7 +18,7 @@ export const ExportCommand = cmd({
   },
   handler: async (args) => {
     await bootstrap(process.cwd(), async () => {
-      let sessionID = args.sessionID
+      let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
       process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
 
       if (!sessionID) {
@@ -58,7 +59,7 @@ export const ExportCommand = cmd({
           throw new UI.CancelledError()
         }
 
-        sessionID = selectedSession as string
+        sessionID = selectedSession
 
         prompts.outro("Exporting session...", {
           output: process.stderr,
@@ -67,7 +68,7 @@ export const ExportCommand = cmd({
 
       try {
         const sessionInfo = await Session.get(sessionID!)
-        const messages = await Session.messages({ sessionID: sessionID! })
+        const messages = await Session.messages({ sessionID: sessionInfo.id })
 
         const exportData = {
           info: sessionInfo,

+ 2 - 1
packages/opencode/src/cli/cmd/github.ts

@@ -22,6 +22,7 @@ import { ModelsDev } from "../../provider/models"
 import { Instance } from "@/project/instance"
 import { bootstrap } from "../bootstrap"
 import { Session } from "../../session"
+import type { SessionID } from "../../session/schema"
 import { Identifier } from "../../id/id"
 import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
@@ -481,7 +482,7 @@ export const GithubRunCommand = cmd({
       let octoRest: Octokit
       let octoGraph: typeof graphql
       let gitConfig: string
-      let session: { id: string; title: string; version: string }
+      let session: { id: SessionID; title: string; version: string }
       let shareId: string | undefined
       let exitCode = 0
       type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]

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

@@ -1,6 +1,7 @@
 import type { Argv } from "yargs"
 import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
 import { Session } from "../../session"
+import { SessionID } from "../../session/schema"
 import { cmd } from "./cmd"
 import { bootstrap } from "../bootstrap"
 import { Database } from "../../storage/db"
@@ -152,7 +153,12 @@ export const ImportCommand = cmd({
         return
       }
 
-      const row = Session.toRow({ ...exportData.info, projectID: Instance.project.id })
+      const row = Session.toRow({
+        ...exportData.info,
+        id: SessionID.make(exportData.info.id),
+        parentID: exportData.info.parentID ? SessionID.make(exportData.info.parentID) : undefined,
+        projectID: Instance.project.id,
+      })
       Database.use((db) =>
         db
           .insert(SessionTable)
@@ -167,7 +173,7 @@ export const ImportCommand = cmd({
             .insert(MessageTable)
             .values({
               id: msg.info.id,
-              session_id: exportData.info.id,
+              session_id: row.id,
               time_created: msg.info.time?.created ?? Date.now(),
               data: msg.info,
             })
@@ -182,7 +188,7 @@ export const ImportCommand = cmd({
               .values({
                 id: part.id,
                 message_id: msg.info.id,
-                session_id: exportData.info.id,
+                session_id: row.id,
                 data: part,
               })
               .onConflictDoNothing()

+ 4 - 2
packages/opencode/src/cli/cmd/session.ts

@@ -1,6 +1,7 @@
 import type { Argv } from "yargs"
 import { cmd } from "./cmd"
 import { Session } from "../../session"
+import { SessionID } from "../../session/schema"
 import { bootstrap } from "../bootstrap"
 import { UI } from "../ui"
 import { Locale } from "../../util/locale"
@@ -57,13 +58,14 @@ export const SessionDeleteCommand = cmd({
   },
   handler: async (args) => {
     await bootstrap(process.cwd(), async () => {
+      const sessionID = SessionID.make(args.sessionID)
       try {
-        await Session.get(args.sessionID)
+        await Session.get(sessionID)
       } catch {
         UI.error(`Session not found: ${args.sessionID}`)
         process.exit(1)
       }
-      await Session.remove(args.sessionID)
+      await Session.remove(sessionID)
       UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
     })
   },

+ 2 - 1
packages/opencode/src/cli/cmd/tui/event.ts

@@ -1,5 +1,6 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
+import { SessionID } from "@/session/schema"
 import z from "zod"
 
 export const TuiEvent = {
@@ -42,7 +43,7 @@ export const TuiEvent = {
   SessionSelect: BusEvent.define(
     "tui.session.select",
     z.object({
-      sessionID: z.string().regex(/^ses/).describe("Session ID to navigate to"),
+      sessionID: SessionID.zod.describe("Session ID to navigate to"),
     }),
   ),
 }

+ 2 - 1
packages/opencode/src/command/index.ts

@@ -1,4 +1,5 @@
 import { BusEvent } from "@/bus/bus-event"
+import { SessionID } from "@/session/schema"
 import z from "zod"
 import { Config } from "../config/config"
 import { Instance } from "../project/instance"
@@ -14,7 +15,7 @@ export namespace Command {
       "command.executed",
       z.object({
         name: z.string(),
-        sessionID: Identifier.schema("session"),
+        sessionID: SessionID.zod,
         arguments: z.string(),
         messageID: Identifier.schema("message"),
       }),

+ 3 - 2
packages/opencode/src/permission/index.ts

@@ -1,5 +1,6 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
+import { SessionID } from "@/session/schema"
 import z from "zod"
 import { Log } from "../util/log"
 import { Identifier } from "../id/id"
@@ -24,7 +25,7 @@ export namespace Permission {
       id: z.string(),
       type: z.string(),
       pattern: z.union([z.string(), z.array(z.string())]).optional(),
-      sessionID: z.string(),
+      sessionID: SessionID.zod,
       messageID: z.string(),
       callID: z.string().optional(),
       message: z.string(),
@@ -43,7 +44,7 @@ export namespace Permission {
     Replied: BusEvent.define(
       "permission.replied",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         permissionID: z.string(),
         response: z.string(),
       }),

+ 3 - 2
packages/opencode/src/permission/next.ts

@@ -2,6 +2,7 @@ import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { Config } from "@/config/config"
 import { Identifier } from "@/id/id"
+import { SessionID } from "@/session/schema"
 import { Instance } from "@/project/instance"
 import { Database, eq } from "@/storage/db"
 import { PermissionTable } from "@/session/session.sql"
@@ -69,7 +70,7 @@ export namespace PermissionNext {
   export const Request = z
     .object({
       id: Identifier.schema("permission"),
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       permission: z.string(),
       patterns: z.string().array(),
       metadata: z.record(z.string(), z.any()),
@@ -100,7 +101,7 @@ export namespace PermissionNext {
     Replied: BusEvent.define(
       "permission.replied",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         requestID: z.string(),
         reply: Reply,
       }),

+ 5 - 4
packages/opencode/src/question/index.ts

@@ -1,6 +1,7 @@
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { Identifier } from "@/id/id"
+import { SessionID } from "@/session/schema"
 import { Instance } from "@/project/instance"
 import { Log } from "@/util/log"
 import z from "zod"
@@ -34,7 +35,7 @@ export namespace Question {
   export const Request = z
     .object({
       id: Identifier.schema("question"),
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       questions: z.array(Info).describe("Questions to ask"),
       tool: z
         .object({
@@ -65,7 +66,7 @@ export namespace Question {
     Replied: BusEvent.define(
       "question.replied",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         requestID: z.string(),
         answers: z.array(Answer),
       }),
@@ -73,7 +74,7 @@ export namespace Question {
     Rejected: BusEvent.define(
       "question.rejected",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         requestID: z.string(),
       }),
     ),
@@ -95,7 +96,7 @@ export namespace Question {
   })
 
   export async function ask(input: {
-    sessionID: string
+    sessionID: SessionID
     questions: Info[]
     tool?: { messageID: string; callID: string }
   }): Promise<Answer[]> {

+ 19 - 18
packages/opencode/src/server/routes/session.ts

@@ -1,6 +1,7 @@
 import { Hono } from "hono"
 import { stream } from "hono/streaming"
 import { describeRoute, validator, resolver } from "hono-openapi"
+import { SessionID } from "@/session/schema"
 import z from "zod"
 import { Session } from "../../session"
 import { MessageV2 } from "../../session/message-v2"
@@ -173,7 +174,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       async (c) => {
@@ -258,7 +259,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string(),
+          sessionID: SessionID.zod,
         }),
       ),
       validator(
@@ -309,7 +310,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       validator("json", Session.initialize.schema.omit({ sessionID: true })),
@@ -372,7 +373,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string(),
+          sessionID: SessionID.zod,
         }),
       ),
       async (c) => {
@@ -401,7 +402,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string(),
+          sessionID: SessionID.zod,
         }),
       ),
       async (c) => {
@@ -502,7 +503,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       validator(
@@ -561,7 +562,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       validator(
@@ -605,7 +606,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
           messageID: z.string().meta({ description: "Message ID" }),
         }),
       ),
@@ -640,7 +641,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
           messageID: z.string().meta({ description: "Message ID" }),
         }),
       ),
@@ -674,7 +675,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
           messageID: z.string().meta({ description: "Message ID" }),
           partID: z.string().meta({ description: "Part ID" }),
         }),
@@ -709,7 +710,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
           messageID: z.string().meta({ description: "Message ID" }),
           partID: z.string().meta({ description: "Part ID" }),
         }),
@@ -753,7 +754,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
@@ -785,7 +786,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
@@ -825,7 +826,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
@@ -857,7 +858,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string().meta({ description: "Session ID" }),
+          sessionID: SessionID.zod,
         }),
       ),
       validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
@@ -889,7 +890,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string(),
+          sessionID: SessionID.zod,
         }),
       ),
       validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
@@ -924,7 +925,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string(),
+          sessionID: SessionID.zod,
         }),
       ),
       async (c) => {
@@ -955,7 +956,7 @@ export const SessionRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          sessionID: z.string(),
+          sessionID: SessionID.zod,
           permissionID: z.string(),
         }),
       ),

+ 5 - 4
packages/opencode/src/session/compaction.ts

@@ -2,6 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { Session } from "."
 import { Identifier } from "../id/id"
+import { SessionID } from "./schema"
 import { Instance } from "../project/instance"
 import { Provider } from "../provider/provider"
 import { MessageV2 } from "./message-v2"
@@ -22,7 +23,7 @@ export namespace SessionCompaction {
     Compacted: BusEvent.define(
       "session.compacted",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
       }),
     ),
   }
@@ -55,7 +56,7 @@ export namespace SessionCompaction {
   // goes backwards through parts until there are 40_000 tokens worth of tool
   // calls. then erases output of previous tool calls. idea is to throw away old
   // tool calls that are no longer relevant.
-  export async function prune(input: { sessionID: string }) {
+  export async function prune(input: { sessionID: SessionID }) {
     const config = await Config.get()
     if (config.compaction?.prune === false) return
     log.info("pruning")
@@ -101,7 +102,7 @@ export namespace SessionCompaction {
   export async function process(input: {
     parentID: string
     messages: MessageV2.WithParts[]
-    sessionID: string
+    sessionID: SessionID
     abort: AbortSignal
     auto: boolean
     overflow?: boolean
@@ -295,7 +296,7 @@ When constructing the summary, try to stick to this template:
 
   export const create = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       agent: z.string(),
       model: z.object({
         providerID: z.string(),

+ 28 - 27
packages/opencode/src/session/index.ts

@@ -24,6 +24,7 @@ import { Command } from "../command"
 import { Snapshot } from "@/snapshot"
 import { WorkspaceContext } from "../control-plane/workspace-context"
 import { ProjectID } from "../project/schema"
+import { SessionID } from "./schema"
 
 import type { Provider } from "@/provider/provider"
 import { PermissionNext } from "@/permission/next"
@@ -119,12 +120,12 @@ export namespace Session {
 
   export const Info = z
     .object({
-      id: Identifier.schema("session"),
+      id: SessionID.zod,
       slug: z.string(),
       projectID: ProjectID.zod,
       workspaceID: z.string().optional(),
       directory: z.string(),
-      parentID: Identifier.schema("session").optional(),
+      parentID: SessionID.zod.optional(),
       summary: z
         .object({
           additions: z.number(),
@@ -201,14 +202,14 @@ export namespace Session {
     Diff: BusEvent.define(
       "session.diff",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         diff: Snapshot.FileDiff.array(),
       }),
     ),
     Error: BusEvent.define(
       "session.error",
       z.object({
-        sessionID: z.string().optional(),
+        sessionID: SessionID.zod.optional(),
         error: MessageV2.Assistant.shape.error,
       }),
     ),
@@ -217,7 +218,7 @@ export namespace Session {
   export const create = fn(
     z
       .object({
-        parentID: Identifier.schema("session").optional(),
+        parentID: SessionID.zod.optional(),
         title: z.string().optional(),
         permission: Info.shape.permission,
         workspaceID: Identifier.schema("workspace").optional(),
@@ -236,7 +237,7 @@ export namespace Session {
 
   export const fork = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       messageID: Identifier.schema("message").optional(),
     }),
     async (input) => {
@@ -277,7 +278,7 @@ export namespace Session {
     },
   )
 
-  export const touch = fn(Identifier.schema("session"), async (sessionID) => {
+  export const touch = fn(SessionID.zod, async (sessionID) => {
     const now = Date.now()
     Database.use((db) => {
       const row = db
@@ -293,15 +294,15 @@ export namespace Session {
   })
 
   export async function createNext(input: {
-    id?: string
+    id?: SessionID
     title?: string
-    parentID?: string
+    parentID?: SessionID
     workspaceID?: string
     directory: string
     permission?: PermissionNext.Ruleset
   }) {
     const result: Info = {
-      id: Identifier.descending("session", input.id),
+      id: SessionID.descending(input.id),
       slug: Slug.create(),
       version: Installation.VERSION,
       projectID: Instance.project.id,
@@ -342,13 +343,13 @@ export namespace Session {
     return path.join(base, [input.time.created, input.slug].join("-") + ".md")
   }
 
-  export const get = fn(Identifier.schema("session"), async (id) => {
+  export const get = fn(SessionID.zod, async (id) => {
     const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
     if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
     return fromRow(row)
   })
 
-  export const share = fn(Identifier.schema("session"), async (id) => {
+  export const share = fn(SessionID.zod, async (id) => {
     const cfg = await Config.get()
     if (cfg.share === "disabled") {
       throw new Error("Sharing is disabled in configuration")
@@ -364,7 +365,7 @@ export namespace Session {
     return share
   })
 
-  export const unshare = fn(Identifier.schema("session"), async (id) => {
+  export const unshare = fn(SessionID.zod, async (id) => {
     // Use ShareNext to remove the share (same as share function uses ShareNext to create)
     const { ShareNext } = await import("@/share/share-next")
     await ShareNext.remove(id)
@@ -378,7 +379,7 @@ export namespace Session {
 
   export const setTitle = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       title: z.string(),
     }),
     async (input) => {
@@ -399,7 +400,7 @@ export namespace Session {
 
   export const setArchived = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       time: z.number().optional(),
     }),
     async (input) => {
@@ -420,7 +421,7 @@ export namespace Session {
 
   export const setPermission = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       permission: PermissionNext.Ruleset,
     }),
     async (input) => {
@@ -441,7 +442,7 @@ export namespace Session {
 
   export const setRevert = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       revert: Info.shape.revert,
       summary: Info.shape.summary,
     }),
@@ -467,7 +468,7 @@ export namespace Session {
     },
   )
 
-  export const clearRevert = fn(Identifier.schema("session"), async (sessionID) => {
+  export const clearRevert = fn(SessionID.zod, async (sessionID) => {
     return Database.use((db) => {
       const row = db
         .update(SessionTable)
@@ -487,7 +488,7 @@ export namespace Session {
 
   export const setSummary = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       summary: Info.shape.summary,
     }),
     async (input) => {
@@ -511,7 +512,7 @@ export namespace Session {
     },
   )
 
-  export const diff = fn(Identifier.schema("session"), async (sessionID) => {
+  export const diff = fn(SessionID.zod, async (sessionID) => {
     try {
       return await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
     } catch {
@@ -521,7 +522,7 @@ export namespace Session {
 
   export const messages = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       limit: z.number().optional(),
     }),
     async (input) => {
@@ -647,7 +648,7 @@ export namespace Session {
     }
   }
 
-  export const children = fn(Identifier.schema("session"), async (parentID) => {
+  export const children = fn(SessionID.zod, async (parentID) => {
     const project = Instance.project
     const rows = Database.use((db) =>
       db
@@ -659,7 +660,7 @@ export namespace Session {
     return rows.map(fromRow)
   })
 
-  export const remove = fn(Identifier.schema("session"), async (sessionID) => {
+  export const remove = fn(SessionID.zod, async (sessionID) => {
     const project = Instance.project
     try {
       const session = await get(sessionID)
@@ -705,7 +706,7 @@ export namespace Session {
 
   export const removeMessage = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       messageID: Identifier.schema("message"),
     }),
     async (input) => {
@@ -727,7 +728,7 @@ export namespace Session {
 
   export const removePart = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       messageID: Identifier.schema("message"),
       partID: Identifier.schema("part"),
     }),
@@ -775,7 +776,7 @@ export namespace Session {
 
   export const updatePartDelta = fn(
     z.object({
-      sessionID: z.string(),
+      sessionID: SessionID.zod,
       messageID: z.string(),
       partID: z.string(),
       field: z.string(),
@@ -873,7 +874,7 @@ export namespace Session {
 
   export const initialize = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       modelID: z.string(),
       providerID: z.string(),
       messageID: Identifier.schema("message"),

+ 8 - 7
packages/opencode/src/session/message-v2.ts

@@ -1,4 +1,5 @@
 import { BusEvent } from "@/bus/bus-event"
+import { SessionID } from "./schema"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
@@ -79,7 +80,7 @@ export namespace MessageV2 {
 
   const PartBase = z.object({
     id: z.string(),
-    sessionID: z.string(),
+    sessionID: SessionID.zod,
     messageID: z.string(),
   })
 
@@ -344,7 +345,7 @@ export namespace MessageV2 {
 
   const Base = z.object({
     id: z.string(),
-    sessionID: z.string(),
+    sessionID: SessionID.zod,
   })
 
   export const User = Base.extend({
@@ -457,7 +458,7 @@ export namespace MessageV2 {
     Removed: BusEvent.define(
       "message.removed",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         messageID: z.string(),
       }),
     ),
@@ -470,7 +471,7 @@ export namespace MessageV2 {
     PartDelta: BusEvent.define(
       "message.part.delta",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         messageID: z.string(),
         partID: z.string(),
         field: z.string(),
@@ -480,7 +481,7 @@ export namespace MessageV2 {
     PartRemoved: BusEvent.define(
       "message.part.removed",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         messageID: z.string(),
         partID: z.string(),
       }),
@@ -728,7 +729,7 @@ export namespace MessageV2 {
     )
   }
 
-  export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
+  export const stream = fn(SessionID.zod, async function* (sessionID) {
     const size = 50
     let offset = 0
     while (true) {
@@ -792,7 +793,7 @@ export namespace MessageV2 {
 
   export const get = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       messageID: Identifier.schema("message"),
     }),
     async (input): Promise<WithParts> => {

+ 2 - 1
packages/opencode/src/session/message.ts

@@ -1,4 +1,5 @@
 import z from "zod"
+import { SessionID } from "./schema"
 import { NamedError } from "@opencode-ai/util/error"
 
 export namespace Message {
@@ -142,7 +143,7 @@ export namespace Message {
           error: z
             .discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
             .optional(),
-          sessionID: z.string(),
+          sessionID: SessionID.zod,
           tool: z.record(
             z.string(),
             z

+ 2 - 1
packages/opencode/src/session/processor.ts

@@ -15,6 +15,7 @@ import { Config } from "@/config/config"
 import { SessionCompaction } from "./compaction"
 import { PermissionNext } from "@/permission/next"
 import { Question } from "@/question"
+import type { SessionID } from "./schema"
 
 export namespace SessionProcessor {
   const DOOM_LOOP_THRESHOLD = 3
@@ -25,7 +26,7 @@ export namespace SessionProcessor {
 
   export function create(input: {
     assistantMessage: MessageV2.Assistant
-    sessionID: string
+    sessionID: SessionID
     model: Provider.Model
     abort: AbortSignal
   }) {

+ 8 - 7
packages/opencode/src/session/prompt.ts

@@ -4,6 +4,7 @@ import fs from "fs/promises"
 import z from "zod"
 import { Filesystem } from "../util/filesystem"
 import { Identifier } from "../id/id"
+import { SessionID } from "./schema"
 import { MessageV2 } from "./message-v2"
 import { Log } from "../util/log"
 import { SessionRevert } from "./revert"
@@ -84,13 +85,13 @@ export namespace SessionPrompt {
     },
   )
 
-  export function assertNotBusy(sessionID: string) {
+  export function assertNotBusy(sessionID: SessionID) {
     const match = state()[sessionID]
     if (match) throw new Session.BusyError(sessionID)
   }
 
   export const PromptInput = z.object({
-    sessionID: Identifier.schema("session"),
+    sessionID: SessionID.zod,
     messageID: Identifier.schema("message").optional(),
     model: z
       .object({
@@ -254,7 +255,7 @@ export namespace SessionPrompt {
     return s[sessionID].abort.signal
   }
 
-  export function cancel(sessionID: string) {
+  export function cancel(sessionID: SessionID) {
     log.info("cancel", { sessionID })
     const s = state()
     const match = s[sessionID]
@@ -269,7 +270,7 @@ export namespace SessionPrompt {
   }
 
   export const LoopInput = z.object({
-    sessionID: Identifier.schema("session"),
+    sessionID: SessionID.zod,
     resume_existing: z.boolean().optional(),
   })
   export const loop = fn(LoopInput, async (input) => {
@@ -731,7 +732,7 @@ export namespace SessionPrompt {
     throw new Error("Impossible")
   })
 
-  async function lastModel(sessionID: string) {
+  async function lastModel(sessionID: SessionID) {
     for await (const item of MessageV2.stream(sessionID)) {
       if (item.info.role === "user" && item.info.model) return item.info.model
     }
@@ -1467,7 +1468,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
   }
 
   export const ShellInput = z.object({
-    sessionID: Identifier.schema("session"),
+    sessionID: SessionID.zod,
     agent: z.string(),
     model: z
       .object({
@@ -1719,7 +1720,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
 
   export const CommandInput = z.object({
     messageID: Identifier.schema("message").optional(),
-    sessionID: Identifier.schema("session"),
+    sessionID: SessionID.zod,
     agent: z.string().optional(),
     model: z.string().optional(),
     arguments: z.string(),

+ 3 - 2
packages/opencode/src/session/revert.ts

@@ -1,5 +1,6 @@
 import z from "zod"
 import { Identifier } from "../id/id"
+import { SessionID } from "./schema"
 import { Snapshot } from "../snapshot"
 import { MessageV2 } from "./message-v2"
 import { Session } from "."
@@ -15,7 +16,7 @@ export namespace SessionRevert {
   const log = Log.create({ service: "session.revert" })
 
   export const RevertInput = z.object({
-    sessionID: Identifier.schema("session"),
+    sessionID: SessionID.zod,
     messageID: Identifier.schema("message"),
     partID: Identifier.schema("part").optional(),
   })
@@ -79,7 +80,7 @@ export namespace SessionRevert {
     return session
   }
 
-  export async function unrevert(input: { sessionID: string }) {
+  export async function unrevert(input: { sessionID: SessionID }) {
     log.info("unreverting", input)
     SessionPrompt.assertNotBusy(input.sessionID)
     const session = await Session.get(input.sessionID)

+ 17 - 0
packages/opencode/src/session/schema.ts

@@ -0,0 +1,17 @@
+import { Schema } from "effect"
+import z from "zod"
+
+import { withStatics } from "@/util/schema"
+import { Identifier } from "@/id/id"
+
+const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionId"))
+
+export type SessionID = typeof sessionIdSchema.Type
+
+export const SessionID = sessionIdSchema.pipe(
+  withStatics((schema: typeof sessionIdSchema) => ({
+    make: (id: string) => schema.makeUnsafe(id),
+    descending: (id?: string) => schema.makeUnsafe(Identifier.descending("session", id)),
+    zod: z.string().startsWith("ses").pipe(z.custom<SessionID>()),
+  })),
+)

+ 6 - 3
packages/opencode/src/session/session.sql.ts

@@ -4,6 +4,7 @@ import type { MessageV2 } from "./message-v2"
 import type { Snapshot } from "../snapshot"
 import type { PermissionNext } from "../permission/next"
 import type { ProjectID } from "../project/schema"
+import type { SessionID } from "./schema"
 import { Timestamps } from "../storage/schema.sql"
 
 type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
@@ -12,13 +13,13 @@ type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
 export const SessionTable = sqliteTable(
   "session",
   {
-    id: text().primaryKey(),
+    id: text().$type<SessionID>().primaryKey(),
     project_id: text()
       .$type<ProjectID>()
       .notNull()
       .references(() => ProjectTable.id, { onDelete: "cascade" }),
     workspace_id: text(),
-    parent_id: text(),
+    parent_id: text().$type<SessionID>(),
     slug: text().notNull(),
     directory: text().notNull(),
     title: text().notNull(),
@@ -46,6 +47,7 @@ export const MessageTable = sqliteTable(
   {
     id: text().primaryKey(),
     session_id: text()
+      .$type<SessionID>()
       .notNull()
       .references(() => SessionTable.id, { onDelete: "cascade" }),
     ...Timestamps,
@@ -61,7 +63,7 @@ export const PartTable = sqliteTable(
     message_id: text()
       .notNull()
       .references(() => MessageTable.id, { onDelete: "cascade" }),
-    session_id: text().notNull(),
+    session_id: text().$type<SessionID>().notNull(),
     ...Timestamps,
     data: text({ mode: "json" }).notNull().$type<PartData>(),
   },
@@ -72,6 +74,7 @@ export const TodoTable = sqliteTable(
   "todo",
   {
     session_id: text()
+      .$type<SessionID>()
       .notNull()
       .references(() => SessionTable.id, { onDelete: "cascade" }),
     content: text().notNull(),

+ 5 - 4
packages/opencode/src/session/status.ts

@@ -1,6 +1,7 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { Instance } from "@/project/instance"
+import { SessionID } from "./schema"
 import z from "zod"
 
 export namespace SessionStatus {
@@ -28,7 +29,7 @@ export namespace SessionStatus {
     Status: BusEvent.define(
       "session.status",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         status: Info,
       }),
     ),
@@ -36,7 +37,7 @@ export namespace SessionStatus {
     Idle: BusEvent.define(
       "session.idle",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
       }),
     ),
   }
@@ -46,7 +47,7 @@ export namespace SessionStatus {
     return data
   })
 
-  export function get(sessionID: string) {
+  export function get(sessionID: SessionID) {
     return (
       state()[sessionID] ?? {
         type: "idle",
@@ -58,7 +59,7 @@ export namespace SessionStatus {
     return state()
   }
 
-  export function set(sessionID: string, status: Info) {
+  export function set(sessionID: SessionID, status: Info) {
     Bus.publish(Event.Status, {
       sessionID,
       status,

+ 4 - 3
packages/opencode/src/session/summary.ts

@@ -4,6 +4,7 @@ import { Session } from "."
 
 import { MessageV2 } from "./message-v2"
 import { Identifier } from "@/id/id"
+import { SessionID } from "./schema"
 import { Snapshot } from "@/snapshot"
 
 import { Storage } from "@/storage/storage"
@@ -68,7 +69,7 @@ export namespace SessionSummary {
 
   export const summarize = fn(
     z.object({
-      sessionID: z.string(),
+      sessionID: SessionID.zod,
       messageID: z.string(),
     }),
     async (input) => {
@@ -80,7 +81,7 @@ export namespace SessionSummary {
     },
   )
 
-  async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) {
+  async function summarizeSession(input: { sessionID: SessionID; messages: MessageV2.WithParts[] }) {
     const diffs = await computeDiff({ messages: input.messages })
     await Session.setSummary({
       sessionID: input.sessionID,
@@ -113,7 +114,7 @@ export namespace SessionSummary {
 
   export const diff = fn(
     z.object({
-      sessionID: Identifier.schema("session"),
+      sessionID: SessionID.zod,
       messageID: Identifier.schema("message").optional(),
     }),
     async (input) => {

+ 4 - 3
packages/opencode/src/session/todo.ts

@@ -1,5 +1,6 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
+import { SessionID } from "./schema"
 import z from "zod"
 import { Database, eq, asc } from "../storage/db"
 import { TodoTable } from "./session.sql"
@@ -18,13 +19,13 @@ export namespace Todo {
     Updated: BusEvent.define(
       "todo.updated",
       z.object({
-        sessionID: z.string(),
+        sessionID: SessionID.zod,
         todos: z.array(Info),
       }),
     ),
   }
 
-  export function update(input: { sessionID: string; todos: Info[] }) {
+  export function update(input: { sessionID: SessionID; todos: Info[] }) {
     Database.transaction((db) => {
       db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
       if (input.todos.length === 0) return
@@ -43,7 +44,7 @@ export namespace Todo {
     Bus.publish(Event.Updated, input)
   }
 
-  export function get(sessionID: string) {
+  export function get(sessionID: SessionID) {
     const rows = Database.use((db) =>
       db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
     )

+ 6 - 5
packages/opencode/src/share/share-next.ts

@@ -3,6 +3,7 @@ import { Account } from "@/account"
 import { Config } from "@/config/config"
 import { Provider } from "@/provider/provider"
 import { Session } from "@/session"
+import type { SessionID } from "@/session/schema"
 import { MessageV2 } from "@/session/message-v2"
 import { Database, eq } from "@/storage/db"
 import { SessionShareTable } from "./share.sql"
@@ -109,7 +110,7 @@ export namespace ShareNext {
     })
   }
 
-  export async function create(sessionID: string) {
+  export async function create(sessionID: SessionID) {
     if (disabled) return { id: "", url: "", secret: "" }
     log.info("creating share", { sessionID })
     const req = await request()
@@ -140,7 +141,7 @@ export namespace ShareNext {
     return result
   }
 
-  function get(sessionID: string) {
+  function get(sessionID: SessionID) {
     const row = Database.use((db) =>
       db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
     )
@@ -186,7 +187,7 @@ export namespace ShareNext {
   }
 
   const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
-  async function sync(sessionID: string, data: Data[]) {
+  async function sync(sessionID: SessionID, data: Data[]) {
     if (disabled) return
     const existing = queue.get(sessionID)
     if (existing) {
@@ -225,7 +226,7 @@ export namespace ShareNext {
     queue.set(sessionID, { timeout, data: dataMap })
   }
 
-  export async function remove(sessionID: string) {
+  export async function remove(sessionID: SessionID) {
     if (disabled) return
     log.info("removing share", { sessionID })
     const share = get(sessionID)
@@ -248,7 +249,7 @@ export namespace ShareNext {
     Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
   }
 
-  async function fullSync(sessionID: string) {
+  async function fullSync(sessionID: SessionID) {
     log.info("full sync", { sessionID })
     const session = await Session.get(sessionID)
     const diffs = await Session.diff(sessionID)

+ 2 - 1
packages/opencode/src/tool/plan.ts

@@ -7,9 +7,10 @@ import { MessageV2 } from "../session/message-v2"
 import { Identifier } from "../id/id"
 import { Provider } from "../provider/provider"
 import { Instance } from "../project/instance"
+import type { SessionID } from "../session/schema"
 import EXIT_DESCRIPTION from "./plan-exit.txt"
 
-async function getLastModel(sessionID: string) {
+async function getLastModel(sessionID: SessionID) {
   for await (const item of MessageV2.stream(sessionID)) {
     if (item.info.role === "user" && item.info.model) return item.info.model
   }

+ 2 - 1
packages/opencode/src/tool/task.ts

@@ -2,6 +2,7 @@ import { Tool } from "./tool"
 import DESCRIPTION from "./task.txt"
 import z from "zod"
 import { Session } from "../session"
+import { SessionID } from "../session/schema"
 import { MessageV2 } from "../session/message-v2"
 import { Identifier } from "../id/id"
 import { Agent } from "../agent/agent"
@@ -65,7 +66,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
 
       const session = await iife(async () => {
         if (params.task_id) {
-          const found = await Session.get(params.task_id).catch(() => {})
+          const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
           if (found) return found
         }
 

+ 2 - 1
packages/opencode/src/tool/tool.ts

@@ -2,6 +2,7 @@ import z from "zod"
 import type { MessageV2 } from "../session/message-v2"
 import type { Agent } from "../agent/agent"
 import type { PermissionNext } from "../permission/next"
+import type { SessionID } from "../session/schema"
 import { Truncate } from "./truncation"
 
 export namespace Tool {
@@ -14,7 +15,7 @@ export namespace Tool {
   }
 
   export type Context<M extends Metadata = Metadata> = {
-    sessionID: string
+    sessionID: SessionID
     messageID: string
     agent: string
     abort: AbortSignal

+ 7 - 6
packages/opencode/test/cli/github-action.test.ts

@@ -1,12 +1,13 @@
 import { test, expect, describe } from "bun:test"
 import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
 import type { MessageV2 } from "../../src/session/message-v2"
+import { SessionID } from "../../src/session/schema"
 
 // Helper to create minimal valid parts
 function createTextPart(text: string): MessageV2.Part {
   return {
     id: "1",
-    sessionID: "s",
+    sessionID: SessionID.make("s"),
     messageID: "m",
     type: "text" as const,
     text,
@@ -16,7 +17,7 @@ function createTextPart(text: string): MessageV2.Part {
 function createReasoningPart(text: string): MessageV2.Part {
   return {
     id: "1",
-    sessionID: "s",
+    sessionID: SessionID.make("s"),
     messageID: "m",
     type: "reasoning" as const,
     text,
@@ -28,7 +29,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
   if (status === "completed") {
     return {
       id: "1",
-      sessionID: "s",
+      sessionID: SessionID.make("s"),
       messageID: "m",
       type: "tool" as const,
       callID: "c1",
@@ -45,7 +46,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
   }
   return {
     id: "1",
-    sessionID: "s",
+    sessionID: SessionID.make("s"),
     messageID: "m",
     type: "tool" as const,
     callID: "c1",
@@ -61,7 +62,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
 function createStepStartPart(): MessageV2.Part {
   return {
     id: "1",
-    sessionID: "s",
+    sessionID: SessionID.make("s"),
     messageID: "m",
     type: "step-start" as const,
   }
@@ -70,7 +71,7 @@ function createStepStartPart(): MessageV2.Part {
 function createStepFinishPart(): MessageV2.Part {
   return {
     id: "1",
-    sessionID: "s",
+    sessionID: SessionID.make("s"),
     messageID: "m",
     type: "step-finish" as const,
     reason: "done",

+ 2 - 1
packages/opencode/test/memory/abort-leak.test.ts

@@ -2,11 +2,12 @@ import { describe, test, expect } from "bun:test"
 import path from "path"
 import { Instance } from "../../src/project/instance"
 import { WebFetchTool } from "../../src/tool/webfetch"
+import { SessionID } from "../../src/session/schema"
 
 const projectRoot = path.join(__dirname, "../..")
 
 const ctx = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "",
   callID: "",
   agent: "build",

+ 12 - 11
packages/opencode/test/permission/next.test.ts

@@ -3,6 +3,7 @@ import os from "os"
 import { PermissionNext } from "../../src/permission/next"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
+import { SessionID } from "../../src/session/schema"
 
 // fromConfig tests
 
@@ -462,7 +463,7 @@ test("ask - resolves immediately when action is allow", async () => {
     directory: tmp.path,
     fn: async () => {
       const result = await PermissionNext.ask({
-        sessionID: "session_test",
+        sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
         metadata: {},
@@ -481,7 +482,7 @@ test("ask - throws RejectedError when action is deny", async () => {
     fn: async () => {
       await expect(
         PermissionNext.ask({
-          sessionID: "session_test",
+          sessionID: SessionID.make("session_test"),
           permission: "bash",
           patterns: ["rm -rf /"],
           metadata: {},
@@ -499,7 +500,7 @@ test("ask - returns pending promise when action is ask", async () => {
     directory: tmp.path,
     fn: async () => {
       const promise = PermissionNext.ask({
-        sessionID: "session_test",
+        sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
         metadata: {},
@@ -522,7 +523,7 @@ test("reply - once resolves the pending ask", async () => {
     fn: async () => {
       const askPromise = PermissionNext.ask({
         id: "permission_test1",
-        sessionID: "session_test",
+        sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
         metadata: {},
@@ -547,7 +548,7 @@ test("reply - reject throws RejectedError", async () => {
     fn: async () => {
       const askPromise = PermissionNext.ask({
         id: "permission_test2",
-        sessionID: "session_test",
+        sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
         metadata: {},
@@ -572,7 +573,7 @@ test("reply - always persists approval and resolves", async () => {
     fn: async () => {
       const askPromise = PermissionNext.ask({
         id: "permission_test3",
-        sessionID: "session_test",
+        sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
         metadata: {},
@@ -594,7 +595,7 @@ test("reply - always persists approval and resolves", async () => {
     fn: async () => {
       // Stored approval should allow without asking
       const result = await PermissionNext.ask({
-        sessionID: "session_test2",
+        sessionID: SessionID.make("session_test2"),
         permission: "bash",
         patterns: ["ls"],
         metadata: {},
@@ -613,7 +614,7 @@ test("reply - reject cancels all pending for same session", async () => {
     fn: async () => {
       const askPromise1 = PermissionNext.ask({
         id: "permission_test4a",
-        sessionID: "session_same",
+        sessionID: SessionID.make("session_same"),
         permission: "bash",
         patterns: ["ls"],
         metadata: {},
@@ -623,7 +624,7 @@ test("reply - reject cancels all pending for same session", async () => {
 
       const askPromise2 = PermissionNext.ask({
         id: "permission_test4b",
-        sessionID: "session_same",
+        sessionID: SessionID.make("session_same"),
         permission: "edit",
         patterns: ["foo.ts"],
         metadata: {},
@@ -655,7 +656,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
     fn: async () => {
       await expect(
         PermissionNext.ask({
-          sessionID: "session_test",
+          sessionID: SessionID.make("session_test"),
           permission: "bash",
           patterns: ["echo hello", "rm -rf /"],
           metadata: {},
@@ -676,7 +677,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
     directory: tmp.path,
     fn: async () => {
       const result = await PermissionNext.ask({
-        sessionID: "session_test",
+        sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["echo hello", "ls -la", "pwd"],
         metadata: {},

+ 10 - 9
packages/opencode/test/question/question.test.ts

@@ -2,6 +2,7 @@ import { test, expect } from "bun:test"
 import { Question } from "../../src/question"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
+import { SessionID } from "../../src/session/schema"
 
 test("ask - returns pending promise", async () => {
   await using tmp = await tmpdir({ git: true })
@@ -9,7 +10,7 @@ test("ask - returns pending promise", async () => {
     directory: tmp.path,
     fn: async () => {
       const promise = Question.ask({
-        sessionID: "ses_test",
+        sessionID: SessionID.make("ses_test"),
         questions: [
           {
             question: "What would you like to do?",
@@ -43,7 +44,7 @@ test("ask - adds to pending list", async () => {
       ]
 
       Question.ask({
-        sessionID: "ses_test",
+        sessionID: SessionID.make("ses_test"),
         questions,
       })
 
@@ -73,7 +74,7 @@ test("reply - resolves the pending ask with answers", async () => {
       ]
 
       const askPromise = Question.ask({
-        sessionID: "ses_test",
+        sessionID: SessionID.make("ses_test"),
         questions,
       })
 
@@ -97,7 +98,7 @@ test("reply - removes from pending list", async () => {
     directory: tmp.path,
     fn: async () => {
       Question.ask({
-        sessionID: "ses_test",
+        sessionID: SessionID.make("ses_test"),
         questions: [
           {
             question: "What would you like to do?",
@@ -146,7 +147,7 @@ test("reject - throws RejectedError", async () => {
     directory: tmp.path,
     fn: async () => {
       const askPromise = Question.ask({
-        sessionID: "ses_test",
+        sessionID: SessionID.make("ses_test"),
         questions: [
           {
             question: "What would you like to do?",
@@ -173,7 +174,7 @@ test("reject - removes from pending list", async () => {
     directory: tmp.path,
     fn: async () => {
       const askPromise = Question.ask({
-        sessionID: "ses_test",
+        sessionID: SessionID.make("ses_test"),
         questions: [
           {
             question: "What would you like to do?",
@@ -236,7 +237,7 @@ test("ask - handles multiple questions", async () => {
       ]
 
       const askPromise = Question.ask({
-        sessionID: "ses_test",
+        sessionID: SessionID.make("ses_test"),
         questions,
       })
 
@@ -261,7 +262,7 @@ test("list - returns all pending requests", async () => {
     directory: tmp.path,
     fn: async () => {
       Question.ask({
-        sessionID: "ses_test1",
+        sessionID: SessionID.make("ses_test1"),
         questions: [
           {
             question: "Question 1?",
@@ -272,7 +273,7 @@ test("list - returns all pending requests", async () => {
       })
 
       Question.ask({
-        sessionID: "ses_test2",
+        sessionID: SessionID.make("ses_test2"),
         questions: [
           {
             question: "Question 2?",

+ 5 - 4
packages/opencode/test/session/llm.test.ts

@@ -11,6 +11,7 @@ import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 import type { Agent } from "../../src/agent/agent"
 import type { MessageV2 } from "../../src/session/message-v2"
+import { SessionID } from "../../src/session/schema"
 
 describe("session.llm.hasToolCalls", () => {
   test("returns false for empty messages array", () => {
@@ -265,7 +266,7 @@ describe("session.llm.stream", () => {
       directory: tmp.path,
       fn: async () => {
         const resolved = await Provider.getModel(providerID, model.id)
-        const sessionID = "session-test-1"
+        const sessionID = SessionID.make("session-test-1")
         const agent = {
           name: "test",
           mode: "primary",
@@ -395,7 +396,7 @@ describe("session.llm.stream", () => {
       directory: tmp.path,
       fn: async () => {
         const resolved = await Provider.getModel("openai", model.id)
-        const sessionID = "session-test-2"
+        const sessionID = SessionID.make("session-test-2")
         const agent = {
           name: "test",
           mode: "primary",
@@ -517,7 +518,7 @@ describe("session.llm.stream", () => {
       directory: tmp.path,
       fn: async () => {
         const resolved = await Provider.getModel(providerID, model.id)
-        const sessionID = "session-test-3"
+        const sessionID = SessionID.make("session-test-3")
         const agent = {
           name: "test",
           mode: "primary",
@@ -618,7 +619,7 @@ describe("session.llm.stream", () => {
       directory: tmp.path,
       fn: async () => {
         const resolved = await Provider.getModel(providerID, model.id)
-        const sessionID = "session-test-4"
+        const sessionID = SessionID.make("session-test-4")
         const agent = {
           name: "test",
           mode: "primary",

+ 2 - 1
packages/opencode/test/session/message-v2.test.ts

@@ -2,8 +2,9 @@ import { describe, expect, test } from "bun:test"
 import { APICallError } from "ai"
 import { MessageV2 } from "../../src/session/message-v2"
 import type { Provider } from "../../src/provider/provider"
+import { SessionID } from "../../src/session/schema"
 
-const sessionID = "session"
+const sessionID = SessionID.make("session")
 const model: Provider.Model = {
   id: "test-model",
   providerID: "test",

+ 4 - 3
packages/opencode/test/session/structured-output.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import { MessageV2 } from "../../src/session/message-v2"
 import { SessionPrompt } from "../../src/session/prompt"
+import { SessionID } from "../../src/session/schema"
 
 describe("structured-output.OutputFormat", () => {
   test("parses text format", () => {
@@ -96,7 +97,7 @@ describe("structured-output.UserMessage", () => {
   test("user message accepts outputFormat", () => {
     const result = MessageV2.User.safeParse({
       id: "test-id",
-      sessionID: "test-session",
+      sessionID: SessionID.descending(),
       role: "user",
       time: { created: Date.now() },
       agent: "default",
@@ -112,7 +113,7 @@ describe("structured-output.UserMessage", () => {
   test("user message works without outputFormat (optional)", () => {
     const result = MessageV2.User.safeParse({
       id: "test-id",
-      sessionID: "test-session",
+      sessionID: SessionID.descending(),
       role: "user",
       time: { created: Date.now() },
       agent: "default",
@@ -125,7 +126,7 @@ describe("structured-output.UserMessage", () => {
 describe("structured-output.AssistantMessage", () => {
   const baseAssistantMessage = {
     id: "test-id",
-    sessionID: "test-session",
+    sessionID: SessionID.descending(),
     role: "assistant" as const,
     parentID: "parent-id",
     modelID: "claude-3",

+ 7 - 6
packages/opencode/test/storage/json-migration.test.ts

@@ -11,6 +11,7 @@ import { ProjectTable } from "../../src/project/project.sql"
 import { ProjectID } from "../../src/project/schema"
 import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
 import { SessionShareTable } from "../../src/share/share.sql"
+import { SessionID } from "../../src/session/schema"
 
 // Test fixtures
 const fixtures = {
@@ -220,7 +221,7 @@ describe("JSON to SQLite migration", () => {
     const db = drizzle({ client: sqlite })
     const sessions = db.select().from(SessionTable).all()
     expect(sessions.length).toBe(1)
-    expect(sessions[0].id).toBe("ses_test456def")
+    expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
     expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
     expect(sessions[0].slug).toBe("test-session")
     expect(sessions[0].title).toBe("Test Session Title")
@@ -295,7 +296,7 @@ describe("JSON to SQLite migration", () => {
     const messages = db.select().from(MessageTable).all()
     expect(messages.length).toBe(1)
     expect(messages[0].id).toBe("msg_test789ghi")
-    expect(messages[0].session_id).toBe("ses_test456def")
+    expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
     expect(messages[0].data).not.toHaveProperty("id")
     expect(messages[0].data).not.toHaveProperty("sessionID")
 
@@ -303,7 +304,7 @@ describe("JSON to SQLite migration", () => {
     expect(parts.length).toBe(1)
     expect(parts[0].id).toBe("prt_testabc123")
     expect(parts[0].message_id).toBe("msg_test789ghi")
-    expect(parts[0].session_id).toBe("ses_test456def")
+    expect(parts[0].session_id).toBe(SessionID.make("ses_test456def"))
     expect(parts[0].data).not.toHaveProperty("id")
     expect(parts[0].data).not.toHaveProperty("messageID")
     expect(parts[0].data).not.toHaveProperty("sessionID")
@@ -336,7 +337,7 @@ describe("JSON to SQLite migration", () => {
     const messages = db.select().from(MessageTable).all()
     expect(messages.length).toBe(1)
     expect(messages[0].id).toBe("msg_from_filename") // Uses filename, not JSON id
-    expect(messages[0].session_id).toBe("ses_test456def")
+    expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
   })
 
   test("uses paths for part id and messageID when JSON has different values", async () => {
@@ -426,7 +427,7 @@ describe("JSON to SQLite migration", () => {
     const db = drizzle({ client: sqlite })
     const sessions = db.select().from(SessionTable).all()
     expect(sessions.length).toBe(1)
-    expect(sessions[0].id).toBe("ses_migrated")
+    expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
     expect(sessions[0].project_id).toBe(ProjectID.make(gitBasedProjectID)) // Uses directory, not stale JSON
   })
 
@@ -458,7 +459,7 @@ describe("JSON to SQLite migration", () => {
     const db = drizzle({ client: sqlite })
     const sessions = db.select().from(SessionTable).all()
     expect(sessions.length).toBe(1)
-    expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id
+    expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
     expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
   })
 

+ 2 - 1
packages/opencode/test/tool/apply_patch.test.ts

@@ -4,9 +4,10 @@ import * as fs from "fs/promises"
 import { ApplyPatchTool } from "../../src/tool/apply_patch"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
+import { SessionID } from "../../src/session/schema"
 
 const baseCtx = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/bash.test.ts

@@ -7,9 +7,10 @@ import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 import type { PermissionNext } from "../../src/permission/next"
 import { Truncate } from "../../src/tool/truncation"
+import { SessionID } from "../../src/session/schema"
 
 const ctx = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/edit.test.ts

@@ -5,9 +5,10 @@ import { EditTool } from "../../src/tool/edit"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
 import { FileTime } from "../../src/file/time"
+import { SessionID } from "../../src/session/schema"
 
 const ctx = {
-  sessionID: "test-edit-session",
+  sessionID: SessionID.make("ses_test-edit-session"),
   messageID: "",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/external-directory.test.ts

@@ -4,9 +4,10 @@ import type { Tool } from "../../src/tool/tool"
 import { Instance } from "../../src/project/instance"
 import { assertExternalDirectory } from "../../src/tool/external-directory"
 import type { PermissionNext } from "../../src/permission/next"
+import { SessionID } from "../../src/session/schema"
 
 const baseCtx: Omit<Tool.Context, "ask"> = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/grep.test.ts

@@ -3,9 +3,10 @@ import path from "path"
 import { GrepTool } from "../../src/tool/grep"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
+import { SessionID } from "../../src/session/schema"
 
 const ctx = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/question.test.ts

@@ -2,9 +2,10 @@ import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
 import { z } from "zod"
 import { QuestionTool } from "../../src/tool/question"
 import * as QuestionModule from "../../src/question"
+import { SessionID } from "../../src/session/schema"
 
 const ctx = {
-  sessionID: "test-session",
+  sessionID: SessionID.make("ses_test-session"),
   messageID: "test-message",
   callID: "test-call",
   agent: "test-agent",

+ 2 - 1
packages/opencode/test/tool/read.test.ts

@@ -6,11 +6,12 @@ import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 import { PermissionNext } from "../../src/permission/next"
 import { Agent } from "../../src/agent/agent"
+import { SessionID } from "../../src/session/schema"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 
 const ctx = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/skill.test.ts

@@ -6,9 +6,10 @@ import type { Tool } from "../../src/tool/tool"
 import { Instance } from "../../src/project/instance"
 import { SkillTool } from "../../src/tool/skill"
 import { tmpdir } from "../fixture/fixture"
+import { SessionID } from "../../src/session/schema"
 
 const baseCtx: Omit<Tool.Context, "ask"> = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/webfetch.test.ts

@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test"
 import path from "path"
 import { Instance } from "../../src/project/instance"
 import { WebFetchTool } from "../../src/tool/webfetch"
+import { SessionID } from "../../src/session/schema"
 
 const projectRoot = path.join(import.meta.dir, "../..")
 
 const ctx = {
-  sessionID: "test",
+  sessionID: SessionID.make("ses_test"),
   messageID: "message",
   callID: "",
   agent: "build",

+ 2 - 1
packages/opencode/test/tool/write.test.ts

@@ -4,9 +4,10 @@ import fs from "fs/promises"
 import { WriteTool } from "../../src/tool/write"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
+import { SessionID } from "../../src/session/schema"
 
 const ctx = {
-  sessionID: "test-write-session",
+  sessionID: SessionID.make("ses_test-write-session"),
   messageID: "",
   callID: "",
   agent: "build",