Quellcode durchsuchen

feat(id): brand WorkspaceID through Drizzle and Zod schemas (#16964)

Kit Langton vor 1 Monat
Ursprung
Commit
16a6d6feba
49 geänderte Dateien mit 205 neuen und 157 gelöschten Zeilen
  1. 2 1
      packages/opencode/script/seed-e2e.ts
  2. 2 1
      packages/opencode/src/cli/cmd/debug/agent.ts
  3. 3 2
      packages/opencode/src/cli/cmd/github.ts
  4. 12 5
      packages/opencode/src/cli/cmd/import.ts
  5. 2 1
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  6. 2 2
      packages/opencode/src/command/index.ts
  7. 17 0
      packages/opencode/src/control-plane/schema.ts
  8. 2 2
      packages/opencode/src/control-plane/types.ts
  9. 3 2
      packages/opencode/src/control-plane/workspace-context.ts
  10. 4 3
      packages/opencode/src/control-plane/workspace-server/server.ts
  11. 2 1
      packages/opencode/src/control-plane/workspace.sql.ts
  12. 5 5
      packages/opencode/src/control-plane/workspace.ts
  13. 2 2
      packages/opencode/src/permission/index.ts
  14. 2 2
      packages/opencode/src/permission/next.ts
  15. 3 3
      packages/opencode/src/question/index.ts
  16. 7 7
      packages/opencode/src/server/routes/session.ts
  17. 3 2
      packages/opencode/src/server/server.ts
  18. 6 6
      packages/opencode/src/session/compaction.ts
  19. 14 13
      packages/opencode/src/session/index.ts
  20. 10 11
      packages/opencode/src/session/message-v2.ts
  21. 1 1
      packages/opencode/src/session/processor.ts
  22. 9 9
      packages/opencode/src/session/prompt.ts
  23. 2 2
      packages/opencode/src/session/revert.ts
  24. 12 0
      packages/opencode/src/session/schema.ts
  25. 6 4
      packages/opencode/src/session/session.sql.ts
  26. 3 3
      packages/opencode/src/session/summary.ts
  27. 3 3
      packages/opencode/src/tool/plan.ts
  28. 2 2
      packages/opencode/src/tool/task.ts
  29. 2 2
      packages/opencode/src/tool/tool.ts
  30. 7 7
      packages/opencode/test/cli/github-action.test.ts
  31. 3 3
      packages/opencode/test/control-plane/session-proxy-middleware.test.ts
  32. 3 3
      packages/opencode/test/control-plane/workspace-sync.test.ts
  33. 2 2
      packages/opencode/test/memory/abort-leak.test.ts
  34. 5 5
      packages/opencode/test/session/llm.test.ts
  35. 2 2
      packages/opencode/test/session/message-v2.test.ts
  36. 7 6
      packages/opencode/test/session/revert-compact.test.ts
  37. 2 1
      packages/opencode/test/session/session.test.ts
  38. 5 5
      packages/opencode/test/session/structured-output.test.ts
  39. 6 6
      packages/opencode/test/storage/json-migration.test.ts
  40. 2 2
      packages/opencode/test/tool/apply_patch.test.ts
  41. 2 2
      packages/opencode/test/tool/bash.test.ts
  42. 2 2
      packages/opencode/test/tool/edit.test.ts
  43. 2 2
      packages/opencode/test/tool/external-directory.test.ts
  44. 2 2
      packages/opencode/test/tool/grep.test.ts
  45. 2 2
      packages/opencode/test/tool/question.test.ts
  46. 2 2
      packages/opencode/test/tool/read.test.ts
  47. 2 2
      packages/opencode/test/tool/skill.test.ts
  48. 2 2
      packages/opencode/test/tool/webfetch.test.ts
  49. 2 2
      packages/opencode/test/tool/write.test.ts

+ 2 - 1
packages/opencode/script/seed-e2e.ts

@@ -12,6 +12,7 @@ const seed = async () => {
   const { InstanceBootstrap } = await import("../src/project/bootstrap")
   const { Session } = await import("../src/session")
   const { Identifier } = await import("../src/id/id")
+  const { MessageID } = await import("../src/session/schema")
   const { Project } = await import("../src/project/project")
 
   await Instance.provide({
@@ -19,7 +20,7 @@ const seed = async () => {
     init: InstanceBootstrap,
     fn: async () => {
       const session = await Session.create({ title })
-      const messageID = Identifier.descending("message")
+      const messageID = MessageID.ascending()
       const partID = Identifier.descending("part")
       const message = {
         id: messageID,

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

@@ -5,6 +5,7 @@ import { Provider } from "../../../provider/provider"
 import { Session } from "../../../session"
 import type { MessageV2 } from "../../../session/message-v2"
 import { Identifier } from "../../../id/id"
+import { MessageID } from "../../../session/schema"
 import { ToolRegistry } from "../../../tool/registry"
 import { Instance } from "../../../project/instance"
 import { PermissionNext } from "../../../permission/next"
@@ -113,7 +114,7 @@ function parseToolParams(input?: string) {
 
 async function createToolContext(agent: Agent.Info) {
   const session = await Session.create({ title: `Debug tool run (${agent.name})` })
-  const messageID = Identifier.ascending("message")
+  const messageID = MessageID.ascending()
   const model = agent.model ?? (await Provider.defaultModel())
   const now = Date.now()
   const message: MessageV2.Assistant = {

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

@@ -24,6 +24,7 @@ import { bootstrap } from "../bootstrap"
 import { Session } from "../../session"
 import type { SessionID } from "../../session/schema"
 import { Identifier } from "../../id/id"
+import { MessageID } from "../../session/schema"
 import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
 import { MessageV2 } from "../../session/message-v2"
@@ -935,7 +936,7 @@ export const GithubRunCommand = cmd({
 
         const result = await SessionPrompt.prompt({
           sessionID: session.id,
-          messageID: Identifier.ascending("message"),
+          messageID: MessageID.ascending(),
           variant,
           model: {
             providerID,
@@ -989,7 +990,7 @@ export const GithubRunCommand = cmd({
         console.log("Requesting summary from agent...")
         const summary = await SessionPrompt.prompt({
           sessionID: session.id,
-          messageID: Identifier.ascending("message"),
+          messageID: MessageID.ascending(),
           variant,
           model: {
             providerID,

+ 12 - 5
packages/opencode/src/cli/cmd/import.ts

@@ -1,7 +1,8 @@
 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 { SessionID, MessageID } from "../../session/schema"
+import { WorkspaceID } from "../../control-plane/schema"
 import { cmd } from "./cmd"
 import { bootstrap } from "../bootstrap"
 import { Database } from "../../storage/db"
@@ -157,7 +158,11 @@ export const ImportCommand = cmd({
         ...exportData.info,
         id: SessionID.make(exportData.info.id),
         parentID: exportData.info.parentID ? SessionID.make(exportData.info.parentID) : undefined,
+        workspaceID: exportData.info.workspaceID ? WorkspaceID.make(exportData.info.workspaceID) : undefined,
         projectID: Instance.project.id,
+        revert: exportData.info.revert
+          ? { ...exportData.info.revert, messageID: MessageID.make(exportData.info.revert.messageID) }
+          : undefined,
       })
       Database.use((db) =>
         db
@@ -168,28 +173,30 @@ export const ImportCommand = cmd({
       )
 
       for (const msg of exportData.messages) {
+        const { id: _mid, sessionID: _msid, ...msgData } = msg.info
         Database.use((db) =>
           db
             .insert(MessageTable)
             .values({
-              id: msg.info.id,
+              id: MessageID.make(msg.info.id),
               session_id: row.id,
               time_created: msg.info.time?.created ?? Date.now(),
-              data: msg.info,
+              data: msgData,
             })
             .onConflictDoNothing()
             .run(),
         )
 
         for (const part of msg.parts) {
+          const { id: _pid, sessionID: _psid, messageID: _pmid, ...partData } = part
           Database.use((db) =>
             db
               .insert(PartTable)
               .values({
                 id: part.id,
-                message_id: msg.info.id,
+                message_id: MessageID.make(msg.info.id),
                 session_id: row.id,
-                data: part,
+                data: partData,
               })
               .onConflictDoNothing()
               .run(),

+ 2 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -10,6 +10,7 @@ import { useSDK } from "@tui/context/sdk"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
 import { Identifier } from "@/id/id"
+import { MessageID } from "@/session/schema"
 import { createStore, produce } from "solid-js/store"
 import { useKeybind } from "@tui/context/keybind"
 import { usePromptHistory, type PromptInfo } from "./history"
@@ -561,7 +562,7 @@ export function Prompt(props: PromptProps) {
       sessionID = res.data.id
     }
 
-    const messageID = Identifier.ascending("message")
+    const messageID = MessageID.ascending()
     let inputText = store.prompt.input
 
     // Expand pasted text inline before submitting

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

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

+ 17 - 0
packages/opencode/src/control-plane/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 workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"))
+
+export type WorkspaceID = typeof workspaceIdSchema.Type
+
+export const WorkspaceID = workspaceIdSchema.pipe(
+  withStatics((schema: typeof workspaceIdSchema) => ({
+    make: (id: string) => schema.makeUnsafe(id),
+    ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
+    zod: z.string().startsWith("wrk").pipe(z.custom<WorkspaceID>()),
+  })),
+)

+ 2 - 2
packages/opencode/src/control-plane/types.ts

@@ -1,9 +1,9 @@
 import z from "zod"
-import { Identifier } from "@/id/id"
 import { ProjectID } from "@/project/schema"
+import { WorkspaceID } from "./schema"
 
 export const WorkspaceInfo = z.object({
-  id: Identifier.schema("workspace"),
+  id: WorkspaceID.zod,
   type: z.string(),
   branch: z.string().nullable(),
   name: z.string().nullable(),

+ 3 - 2
packages/opencode/src/control-plane/workspace-context.ts

@@ -1,13 +1,14 @@
 import { Context } from "../util/context"
+import type { WorkspaceID } from "./schema"
 
 interface Context {
-  workspaceID?: string
+  workspaceID?: WorkspaceID
 }
 
 const context = Context.create<Context>("workspace")
 
 export const WorkspaceContext = {
-  async provide<R>(input: { workspaceID?: string; fn: () => R }): Promise<R> {
+  async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
     return context.provide({ workspaceID: input.workspaceID }, async () => {
       return input.fn()
     })

+ 4 - 3
packages/opencode/src/control-plane/workspace-server/server.ts

@@ -4,6 +4,7 @@ import { InstanceBootstrap } from "../../project/bootstrap"
 import { SessionRoutes } from "../../server/routes/session"
 import { WorkspaceServerRoutes } from "./routes"
 import { WorkspaceContext } from "../workspace-context"
+import { WorkspaceID } from "../schema"
 
 export namespace WorkspaceServer {
   export function App() {
@@ -20,9 +21,9 @@ export namespace WorkspaceServer {
 
     return new Hono()
       .use(async (c, next) => {
-        const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
+        const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
         const raw = c.req.query("directory") || c.req.header("x-opencode-directory")
-        if (workspaceID == null) {
+        if (rawWorkspaceID == null) {
           throw new Error("workspaceID parameter is required")
         }
         if (raw == null) {
@@ -38,7 +39,7 @@ export namespace WorkspaceServer {
         })()
 
         return WorkspaceContext.provide({
-          workspaceID,
+          workspaceID: WorkspaceID.make(rawWorkspaceID),
           async fn() {
             return Instance.provide({
               directory,

+ 2 - 1
packages/opencode/src/control-plane/workspace.sql.ts

@@ -1,9 +1,10 @@
 import { sqliteTable, text } from "drizzle-orm/sqlite-core"
 import { ProjectTable } from "../project/project.sql"
 import type { ProjectID } from "../project/schema"
+import type { WorkspaceID } from "./schema"
 
 export const WorkspaceTable = sqliteTable("workspace", {
-  id: text().primaryKey(),
+  id: text().$type<WorkspaceID>().primaryKey(),
   type: text().notNull(),
   branch: text(),
   name: text(),

+ 5 - 5
packages/opencode/src/control-plane/workspace.ts

@@ -1,5 +1,4 @@
 import z from "zod"
-import { Identifier } from "@/id/id"
 import { fn } from "@/util/fn"
 import { Database, eq } from "@/storage/db"
 import { Project } from "@/project/project"
@@ -10,6 +9,7 @@ import { ProjectID } from "@/project/schema"
 import { WorkspaceTable } from "./workspace.sql"
 import { getAdaptor } from "./adaptors"
 import { WorkspaceInfo } from "./types"
+import { WorkspaceID } from "./schema"
 import { parseSSE } from "./sse"
 
 export namespace Workspace {
@@ -46,7 +46,7 @@ export namespace Workspace {
   }
 
   const CreateInput = z.object({
-    id: Identifier.schema("workspace").optional(),
+    id: WorkspaceID.zod.optional(),
     type: Info.shape.type,
     branch: Info.shape.branch,
     projectID: ProjectID.zod,
@@ -54,7 +54,7 @@ export namespace Workspace {
   })
 
   export const create = fn(CreateInput, async (input) => {
-    const id = Identifier.ascending("workspace", input.id)
+    const id = WorkspaceID.ascending(input.id)
     const adaptor = await getAdaptor(input.type)
 
     const config = await adaptor.configure({ ...input, id, name: null, directory: null })
@@ -94,13 +94,13 @@ export namespace Workspace {
     return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
   }
 
-  export const get = fn(Identifier.schema("workspace"), async (id) => {
+  export const get = fn(WorkspaceID.zod, async (id) => {
     const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
     if (!row) return
     return fromRow(row)
   })
 
-  export const remove = fn(Identifier.schema("workspace"), async (id) => {
+  export const remove = fn(WorkspaceID.zod, async (id) => {
     const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
     if (row) {
       const info = fromRow(row)

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

@@ -1,6 +1,6 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
-import { SessionID } from "@/session/schema"
+import { SessionID, MessageID } from "@/session/schema"
 import z from "zod"
 import { Log } from "../util/log"
 import { Identifier } from "../id/id"
@@ -26,7 +26,7 @@ export namespace Permission {
       type: z.string(),
       pattern: z.union([z.string(), z.array(z.string())]).optional(),
       sessionID: SessionID.zod,
-      messageID: z.string(),
+      messageID: MessageID.zod,
       callID: z.string().optional(),
       message: z.string(),
       metadata: z.record(z.string(), z.any()),

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

@@ -2,7 +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 { SessionID, MessageID } from "@/session/schema"
 import { Instance } from "@/project/instance"
 import { Database, eq } from "@/storage/db"
 import { PermissionTable } from "@/session/session.sql"
@@ -77,7 +77,7 @@ export namespace PermissionNext {
       always: z.string().array(),
       tool: z
         .object({
-          messageID: z.string(),
+          messageID: MessageID.zod,
           callID: z.string(),
         })
         .optional(),

+ 3 - 3
packages/opencode/src/question/index.ts

@@ -1,7 +1,7 @@
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { Identifier } from "@/id/id"
-import { SessionID } from "@/session/schema"
+import { SessionID, MessageID } from "@/session/schema"
 import { Instance } from "@/project/instance"
 import { Log } from "@/util/log"
 import z from "zod"
@@ -39,7 +39,7 @@ export namespace Question {
       questions: z.array(Info).describe("Questions to ask"),
       tool: z
         .object({
-          messageID: z.string(),
+          messageID: MessageID.zod,
           callID: z.string(),
         })
         .optional(),
@@ -98,7 +98,7 @@ export namespace Question {
   export async function ask(input: {
     sessionID: SessionID
     questions: Info[]
-    tool?: { messageID: string; callID: string }
+    tool?: { messageID: MessageID; callID: string }
   }): Promise<Answer[]> {
     const s = await state()
     const id = Identifier.ascending("question")

+ 7 - 7
packages/opencode/src/server/routes/session.ts

@@ -1,7 +1,7 @@
 import { Hono } from "hono"
 import { stream } from "hono/streaming"
 import { describeRoute, validator, resolver } from "hono-openapi"
-import { SessionID } from "@/session/schema"
+import { SessionID, MessageID } from "@/session/schema"
 import z from "zod"
 import { Session } from "../../session"
 import { MessageV2 } from "../../session/message-v2"
@@ -607,7 +607,7 @@ export const SessionRoutes = lazy(() =>
         "param",
         z.object({
           sessionID: SessionID.zod,
-          messageID: z.string().meta({ description: "Message ID" }),
+          messageID: MessageID.zod,
         }),
       ),
       async (c) => {
@@ -642,7 +642,7 @@ export const SessionRoutes = lazy(() =>
         "param",
         z.object({
           sessionID: SessionID.zod,
-          messageID: z.string().meta({ description: "Message ID" }),
+          messageID: MessageID.zod,
         }),
       ),
       async (c) => {
@@ -676,8 +676,8 @@ export const SessionRoutes = lazy(() =>
         "param",
         z.object({
           sessionID: SessionID.zod,
-          messageID: z.string().meta({ description: "Message ID" }),
-          partID: z.string().meta({ description: "Part ID" }),
+          messageID: MessageID.zod,
+          partID: z.string(),
         }),
       ),
       async (c) => {
@@ -711,8 +711,8 @@ export const SessionRoutes = lazy(() =>
         "param",
         z.object({
           sessionID: SessionID.zod,
-          messageID: z.string().meta({ description: "Message ID" }),
-          partID: z.string().meta({ description: "Part ID" }),
+          messageID: MessageID.zod,
+          partID: z.string(),
         }),
       ),
       validator("json", MessageV2.Part),

+ 3 - 2
packages/opencode/src/server/server.ts

@@ -22,6 +22,7 @@ import { Flag } from "../flag/flag"
 import { Command } from "../command"
 import { Global } from "../global"
 import { WorkspaceContext } from "../control-plane/workspace-context"
+import { WorkspaceID } from "../control-plane/schema"
 import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
 import { ProjectRoutes } from "./routes/project"
 import { SessionRoutes } from "./routes/session"
@@ -190,7 +191,7 @@ export namespace Server {
       )
       .use(async (c, next) => {
         if (c.req.path === "/log") return next()
-        const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
+        const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
         const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
         const directory = Filesystem.resolve(
           (() => {
@@ -203,7 +204,7 @@ export namespace Server {
         )
 
         return WorkspaceContext.provide({
-          workspaceID,
+          workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined,
           async fn() {
             return Instance.provide({
               directory,

+ 6 - 6
packages/opencode/src/session/compaction.ts

@@ -2,7 +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 { SessionID, MessageID } from "./schema"
 import { Instance } from "../project/instance"
 import { Provider } from "../provider/provider"
 import { MessageV2 } from "./message-v2"
@@ -100,7 +100,7 @@ export namespace SessionCompaction {
   }
 
   export async function process(input: {
-    parentID: string
+    parentID: MessageID
     messages: MessageV2.WithParts[]
     sessionID: SessionID
     abort: AbortSignal
@@ -134,7 +134,7 @@ export namespace SessionCompaction {
       ? await Provider.getModel(agent.model.providerID, agent.model.modelID)
       : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
     const msg = (await Session.updateMessage({
-      id: Identifier.ascending("message"),
+      id: MessageID.ascending(),
       role: "assistant",
       parentID: input.parentID,
       sessionID: input.sessionID,
@@ -237,7 +237,7 @@ When constructing the summary, try to stick to this template:
       if (replay) {
         const original = replay.info as MessageV2.User
         const replayMsg = await Session.updateMessage({
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "user",
           sessionID: input.sessionID,
           time: { created: Date.now() },
@@ -263,7 +263,7 @@ When constructing the summary, try to stick to this template:
         }
       } else {
         const continueMsg = await Session.updateMessage({
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "user",
           sessionID: input.sessionID,
           time: { created: Date.now() },
@@ -307,7 +307,7 @@ When constructing the summary, try to stick to this template:
     }),
     async (input) => {
       const msg = await Session.updateMessage({
-        id: Identifier.ascending("message"),
+        id: MessageID.ascending(),
         role: "user",
         model: input.model,
         sessionID: input.sessionID,

+ 14 - 13
packages/opencode/src/session/index.ts

@@ -24,7 +24,8 @@ 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 { WorkspaceID } from "../control-plane/schema"
+import { SessionID, MessageID } from "./schema"
 
 import type { Provider } from "@/provider/provider"
 import { PermissionNext } from "@/permission/next"
@@ -123,7 +124,7 @@ export namespace Session {
       id: SessionID.zod,
       slug: z.string(),
       projectID: ProjectID.zod,
-      workspaceID: z.string().optional(),
+      workspaceID: WorkspaceID.zod.optional(),
       directory: z.string(),
       parentID: SessionID.zod.optional(),
       summary: z
@@ -150,7 +151,7 @@ export namespace Session {
       permission: PermissionNext.Ruleset.optional(),
       revert: z
         .object({
-          messageID: z.string(),
+          messageID: MessageID.zod,
           partID: z.string().optional(),
           snapshot: z.string().optional(),
           diff: z.string().optional(),
@@ -221,7 +222,7 @@ export namespace Session {
         parentID: SessionID.zod.optional(),
         title: z.string().optional(),
         permission: Info.shape.permission,
-        workspaceID: Identifier.schema("workspace").optional(),
+        workspaceID: WorkspaceID.zod.optional(),
       })
       .optional(),
     async (input) => {
@@ -238,7 +239,7 @@ export namespace Session {
   export const fork = fn(
     z.object({
       sessionID: SessionID.zod,
-      messageID: Identifier.schema("message").optional(),
+      messageID: MessageID.zod.optional(),
     }),
     async (input) => {
       const original = await get(input.sessionID)
@@ -250,11 +251,11 @@ export namespace Session {
         title,
       })
       const msgs = await messages({ sessionID: input.sessionID })
-      const idMap = new Map<string, string>()
+      const idMap = new Map<string, MessageID>()
 
       for (const msg of msgs) {
         if (input.messageID && msg.info.id >= input.messageID) break
-        const newID = Identifier.ascending("message")
+        const newID = MessageID.ascending()
         idMap.set(msg.info.id, newID)
 
         const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
@@ -297,7 +298,7 @@ export namespace Session {
     id?: SessionID
     title?: string
     parentID?: SessionID
-    workspaceID?: string
+    workspaceID?: WorkspaceID
     directory: string
     permission?: PermissionNext.Ruleset
   }) {
@@ -538,7 +539,7 @@ export namespace Session {
 
   export function* list(input?: {
     directory?: string
-    workspaceID?: string
+    workspaceID?: WorkspaceID
     roots?: boolean
     start?: number
     search?: string
@@ -707,7 +708,7 @@ export namespace Session {
   export const removeMessage = fn(
     z.object({
       sessionID: SessionID.zod,
-      messageID: Identifier.schema("message"),
+      messageID: MessageID.zod,
     }),
     async (input) => {
       // CASCADE delete handles parts automatically
@@ -729,7 +730,7 @@ export namespace Session {
   export const removePart = fn(
     z.object({
       sessionID: SessionID.zod,
-      messageID: Identifier.schema("message"),
+      messageID: MessageID.zod,
       partID: Identifier.schema("part"),
     }),
     async (input) => {
@@ -777,7 +778,7 @@ export namespace Session {
   export const updatePartDelta = fn(
     z.object({
       sessionID: SessionID.zod,
-      messageID: z.string(),
+      messageID: MessageID.zod,
       partID: z.string(),
       field: z.string(),
       delta: z.string(),
@@ -877,7 +878,7 @@ export namespace Session {
       sessionID: SessionID.zod,
       modelID: z.string(),
       providerID: z.string(),
-      messageID: Identifier.schema("message"),
+      messageID: MessageID.zod,
     }),
     async (input) => {
       await SessionPrompt.command({

+ 10 - 11
packages/opencode/src/session/message-v2.ts

@@ -1,9 +1,8 @@
 import { BusEvent } from "@/bus/bus-event"
-import { SessionID } from "./schema"
+import { SessionID, MessageID } from "./schema"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
-import { Identifier } from "../id/id"
 import { LSP } from "../lsp"
 import { Snapshot } from "@/snapshot"
 import { fn } from "@/util/fn"
@@ -81,7 +80,7 @@ export namespace MessageV2 {
   const PartBase = z.object({
     id: z.string(),
     sessionID: SessionID.zod,
-    messageID: z.string(),
+    messageID: MessageID.zod,
   })
 
   export const SnapshotPart = PartBase.extend({
@@ -344,7 +343,7 @@ export namespace MessageV2 {
   export type ToolPart = z.infer<typeof ToolPart>
 
   const Base = z.object({
-    id: z.string(),
+    id: MessageID.zod,
     sessionID: SessionID.zod,
   })
 
@@ -411,7 +410,7 @@ export namespace MessageV2 {
         APIError.Schema,
       ])
       .optional(),
-    parentID: z.string(),
+    parentID: MessageID.zod,
     modelID: z.string(),
     providerID: z.string(),
     /**
@@ -459,7 +458,7 @@ export namespace MessageV2 {
       "message.removed",
       z.object({
         sessionID: SessionID.zod,
-        messageID: z.string(),
+        messageID: MessageID.zod,
       }),
     ),
     PartUpdated: BusEvent.define(
@@ -472,7 +471,7 @@ export namespace MessageV2 {
       "message.part.delta",
       z.object({
         sessionID: SessionID.zod,
-        messageID: z.string(),
+        messageID: MessageID.zod,
         partID: z.string(),
         field: z.string(),
         delta: z.string(),
@@ -482,7 +481,7 @@ export namespace MessageV2 {
       "message.part.removed",
       z.object({
         sessionID: SessionID.zod,
-        messageID: z.string(),
+        messageID: MessageID.zod,
         partID: z.string(),
       }),
     ),
@@ -699,7 +698,7 @@ export namespace MessageV2 {
           // media (images, PDFs) in tool results
           if (media.length > 0) {
             result.push({
-              id: Identifier.ascending("message"),
+              id: MessageID.ascending(),
               role: "user",
               parts: [
                 {
@@ -782,7 +781,7 @@ export namespace MessageV2 {
     }
   })
 
-  export const parts = fn(Identifier.schema("message"), async (message_id) => {
+  export const parts = fn(MessageID.zod, async (message_id) => {
     const rows = Database.use((db) =>
       db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
     )
@@ -794,7 +793,7 @@ export namespace MessageV2 {
   export const get = fn(
     z.object({
       sessionID: SessionID.zod,
-      messageID: Identifier.schema("message"),
+      messageID: MessageID.zod,
     }),
     async (input): Promise<WithParts> => {
       const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get())

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

@@ -15,7 +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"
+import type { SessionID, MessageID } from "./schema"
 
 export namespace SessionProcessor {
   const DOOM_LOOP_THRESHOLD = 3

+ 9 - 9
packages/opencode/src/session/prompt.ts

@@ -4,7 +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 { SessionID, MessageID } from "./schema"
 import { MessageV2 } from "./message-v2"
 import { Log } from "../util/log"
 import { SessionRevert } from "./revert"
@@ -92,7 +92,7 @@ export namespace SessionPrompt {
 
   export const PromptInput = z.object({
     sessionID: SessionID.zod,
-    messageID: Identifier.schema("message").optional(),
+    messageID: MessageID.zod.optional(),
     model: z
       .object({
         providerID: z.string(),
@@ -355,7 +355,7 @@ export namespace SessionPrompt {
         const taskTool = await TaskTool.init()
         const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model
         const assistantMessage = (await Session.updateMessage({
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "assistant",
           parentID: lastUser.id,
           sessionID,
@@ -504,7 +504,7 @@ export namespace SessionPrompt {
           // If we create assistant messages w/ out user ones following mid loop thinking signatures
           // will be missing and it can cause errors for models like gemini for example
           const summaryUserMsg: MessageV2.User = {
-            id: Identifier.ascending("message"),
+            id: MessageID.ascending(),
             sessionID,
             role: "user",
             time: {
@@ -568,7 +568,7 @@ export namespace SessionPrompt {
 
       const processor = SessionProcessor.create({
         assistantMessage: (await Session.updateMessage({
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           parentID: lastUser.id,
           role: "assistant",
           mode: agent.name,
@@ -971,7 +971,7 @@ export namespace SessionPrompt {
     const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined)
 
     const info: MessageV2.Info = {
-      id: input.messageID ?? Identifier.ascending("message"),
+      id: input.messageID ?? MessageID.ascending(),
       role: "user",
       sessionID: input.sessionID,
       time: {
@@ -1505,7 +1505,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
     const agent = await Agent.get(input.agent)
     const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
     const userMsg: MessageV2.User = {
-      id: Identifier.ascending("message"),
+      id: MessageID.ascending(),
       sessionID: input.sessionID,
       time: {
         created: Date.now(),
@@ -1529,7 +1529,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
     await Session.updatePart(userPart)
 
     const msg: MessageV2.Assistant = {
-      id: Identifier.ascending("message"),
+      id: MessageID.ascending(),
       sessionID: input.sessionID,
       parentID: userMsg.id,
       mode: input.agent,
@@ -1719,7 +1719,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(),
+    messageID: MessageID.zod.optional(),
     sessionID: SessionID.zod,
     agent: z.string().optional(),
     model: z.string().optional(),

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

@@ -1,6 +1,6 @@
 import z from "zod"
 import { Identifier } from "../id/id"
-import { SessionID } from "./schema"
+import { SessionID, MessageID } from "./schema"
 import { Snapshot } from "../snapshot"
 import { MessageV2 } from "./message-v2"
 import { Session } from "."
@@ -17,7 +17,7 @@ export namespace SessionRevert {
 
   export const RevertInput = z.object({
     sessionID: SessionID.zod,
-    messageID: Identifier.schema("message"),
+    messageID: MessageID.zod,
     partID: Identifier.schema("part").optional(),
   })
   export type RevertInput = z.infer<typeof RevertInput>

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

@@ -15,3 +15,15 @@ export const SessionID = sessionIdSchema.pipe(
     zod: z.string().startsWith("ses").pipe(z.custom<SessionID>()),
   })),
 )
+
+const messageIdSchema = Schema.String.pipe(Schema.brand("MessageId"))
+
+export type MessageID = typeof messageIdSchema.Type
+
+export const MessageID = messageIdSchema.pipe(
+  withStatics((schema: typeof messageIdSchema) => ({
+    make: (id: string) => schema.makeUnsafe(id),
+    ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)),
+    zod: z.string().startsWith("msg").pipe(z.custom<MessageID>()),
+  })),
+)

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

@@ -4,7 +4,8 @@ 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 type { SessionID, MessageID } from "./schema"
+import type { WorkspaceID } from "../control-plane/schema"
 import { Timestamps } from "../storage/schema.sql"
 
 type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
@@ -18,7 +19,7 @@ export const SessionTable = sqliteTable(
       .$type<ProjectID>()
       .notNull()
       .references(() => ProjectTable.id, { onDelete: "cascade" }),
-    workspace_id: text(),
+    workspace_id: text().$type<WorkspaceID>(),
     parent_id: text().$type<SessionID>(),
     slug: text().notNull(),
     directory: text().notNull(),
@@ -29,7 +30,7 @@ export const SessionTable = sqliteTable(
     summary_deletions: integer(),
     summary_files: integer(),
     summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
-    revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(),
+    revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: string; snapshot?: string; diff?: string }>(),
     permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
     ...Timestamps,
     time_compacting: integer(),
@@ -45,7 +46,7 @@ export const SessionTable = sqliteTable(
 export const MessageTable = sqliteTable(
   "message",
   {
-    id: text().primaryKey(),
+    id: text().$type<MessageID>().primaryKey(),
     session_id: text()
       .$type<SessionID>()
       .notNull()
@@ -61,6 +62,7 @@ export const PartTable = sqliteTable(
   {
     id: text().primaryKey(),
     message_id: text()
+      .$type<MessageID>()
       .notNull()
       .references(() => MessageTable.id, { onDelete: "cascade" }),
     session_id: text().$type<SessionID>().notNull(),

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

@@ -4,7 +4,7 @@ import { Session } from "."
 
 import { MessageV2 } from "./message-v2"
 import { Identifier } from "@/id/id"
-import { SessionID } from "./schema"
+import { SessionID, MessageID } from "./schema"
 import { Snapshot } from "@/snapshot"
 
 import { Storage } from "@/storage/storage"
@@ -70,7 +70,7 @@ export namespace SessionSummary {
   export const summarize = fn(
     z.object({
       sessionID: SessionID.zod,
-      messageID: z.string(),
+      messageID: MessageID.zod,
     }),
     async (input) => {
       const all = await Session.messages({ sessionID: input.sessionID })
@@ -115,7 +115,7 @@ export namespace SessionSummary {
   export const diff = fn(
     z.object({
       sessionID: SessionID.zod,
-      messageID: Identifier.schema("message").optional(),
+      messageID: MessageID.zod.optional(),
     }),
     async (input) => {
       const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])

+ 3 - 3
packages/opencode/src/tool/plan.ts

@@ -7,7 +7,7 @@ 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 { type SessionID, MessageID } from "../session/schema"
 import EXIT_DESCRIPTION from "./plan-exit.txt"
 
 async function getLastModel(sessionID: SessionID) {
@@ -45,7 +45,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
     const model = await getLastModel(ctx.sessionID)
 
     const userMsg: MessageV2.User = {
-      id: Identifier.ascending("message"),
+      id: MessageID.ascending(),
       sessionID: ctx.sessionID,
       role: "user",
       time: {
@@ -103,7 +103,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
     const model = await getLastModel(ctx.sessionID)
 
     const userMsg: MessageV2.User = {
-      id: Identifier.ascending("message"),
+      id: MessageID.ascending(),
       sessionID: ctx.sessionID,
       role: "user",
       time: {

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

@@ -2,7 +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 { SessionID, MessageID } from "../session/schema"
 import { MessageV2 } from "../session/message-v2"
 import { Identifier } from "../id/id"
 import { Agent } from "../agent/agent"
@@ -117,7 +117,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
         },
       })
 
-      const messageID = Identifier.ascending("message")
+      const messageID = MessageID.ascending()
 
       function cancel() {
         SessionPrompt.cancel(session.id)

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

@@ -2,7 +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 type { SessionID, MessageID } from "../session/schema"
 import { Truncate } from "./truncation"
 
 export namespace Tool {
@@ -16,7 +16,7 @@ export namespace Tool {
 
   export type Context<M extends Metadata = Metadata> = {
     sessionID: SessionID
-    messageID: string
+    messageID: MessageID
     agent: string
     abort: AbortSignal
     callID?: string

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

@@ -1,14 +1,14 @@
 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 // Helper to create minimal valid parts
 function createTextPart(text: string): MessageV2.Part {
   return {
     id: "1",
     sessionID: SessionID.make("s"),
-    messageID: "m",
+    messageID: MessageID.make("m"),
     type: "text" as const,
     text,
   }
@@ -18,7 +18,7 @@ function createReasoningPart(text: string): MessageV2.Part {
   return {
     id: "1",
     sessionID: SessionID.make("s"),
-    messageID: "m",
+    messageID: MessageID.make("m"),
     type: "reasoning" as const,
     text,
     time: { start: 0 },
@@ -30,7 +30,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
     return {
       id: "1",
       sessionID: SessionID.make("s"),
-      messageID: "m",
+      messageID: MessageID.make("m"),
       type: "tool" as const,
       callID: "c1",
       tool,
@@ -47,7 +47,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
   return {
     id: "1",
     sessionID: SessionID.make("s"),
-    messageID: "m",
+    messageID: MessageID.make("m"),
     type: "tool" as const,
     callID: "c1",
     tool,
@@ -63,7 +63,7 @@ function createStepStartPart(): MessageV2.Part {
   return {
     id: "1",
     sessionID: SessionID.make("s"),
-    messageID: "m",
+    messageID: MessageID.make("m"),
     type: "step-start" as const,
   }
 }
@@ -72,7 +72,7 @@ function createStepFinishPart(): MessageV2.Part {
   return {
     id: "1",
     sessionID: SessionID.make("s"),
-    messageID: "m",
+    messageID: MessageID.make("m"),
     type: "step-finish" as const,
     reason: "done",
     cost: 0,

+ 3 - 3
packages/opencode/test/control-plane/session-proxy-middleware.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, mock, test } from "bun:test"
-import { Identifier } from "../../src/id/id"
+import { WorkspaceID } from "../../src/control-plane/schema"
 import { Hono } from "hono"
 import { tmpdir } from "../fixture/fixture"
 import { Project } from "../../src/project/project"
@@ -64,8 +64,8 @@ async function setup(state: State) {
   await using tmp = await tmpdir({ git: true })
   const { project } = await Project.fromDirectory(tmp.path)
 
-  const id1 = Identifier.descending("workspace")
-  const id2 = Identifier.descending("workspace")
+  const id1 = WorkspaceID.ascending()
+  const id2 = WorkspaceID.ascending()
 
   Database.use((db) =>
     db

+ 3 - 3
packages/opencode/test/control-plane/workspace-sync.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, mock, test } from "bun:test"
-import { Identifier } from "../../src/id/id"
+import { WorkspaceID } from "../../src/control-plane/schema"
 import { Log } from "../../src/util/log"
 import { tmpdir } from "../fixture/fixture"
 import { Project } from "../../src/project/project"
@@ -52,8 +52,8 @@ describe("control-plane/workspace.startSyncing", () => {
     await using tmp = await tmpdir({ git: true })
     const { project } = await Project.fromDirectory(tmp.path)
 
-    const id1 = Identifier.descending("workspace")
-    const id2 = Identifier.descending("workspace")
+    const id1 = WorkspaceID.ascending()
+    const id2 = WorkspaceID.ascending()
 
     Database.use((db) =>
       db

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

@@ -2,13 +2,13 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const projectRoot = path.join(__dirname, "../..")
 
 const ctx = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: new AbortController().signal,

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

@@ -11,7 +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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 describe("session.llm.hasToolCalls", () => {
   test("returns false for empty messages array", () => {
@@ -277,7 +277,7 @@ describe("session.llm.stream", () => {
         } satisfies Agent.Info
 
         const user = {
-          id: "user-1",
+          id: MessageID.make("user-1"),
           sessionID,
           role: "user",
           time: { created: Date.now() },
@@ -406,7 +406,7 @@ describe("session.llm.stream", () => {
         } satisfies Agent.Info
 
         const user = {
-          id: "user-2",
+          id: MessageID.make("user-2"),
           sessionID,
           role: "user",
           time: { created: Date.now() },
@@ -529,7 +529,7 @@ describe("session.llm.stream", () => {
         } satisfies Agent.Info
 
         const user = {
-          id: "user-3",
+          id: MessageID.make("user-3"),
           sessionID,
           role: "user",
           time: { created: Date.now() },
@@ -630,7 +630,7 @@ describe("session.llm.stream", () => {
         } satisfies Agent.Info
 
         const user = {
-          id: "user-4",
+          id: MessageID.make("user-4"),
           sessionID,
           role: "user",
           time: { created: Date.now() },

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

@@ -2,7 +2,7 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const sessionID = SessionID.make("session")
 const model: Provider.Model = {
@@ -100,7 +100,7 @@ function basePart(messageID: string, id: string) {
   return {
     id,
     sessionID,
-    messageID,
+    messageID: MessageID.make(messageID),
   }
 }
 

+ 7 - 6
packages/opencode/test/session/revert-compact.test.ts

@@ -7,6 +7,7 @@ import { MessageV2 } from "../../src/session/message-v2"
 import { Log } from "../../src/util/log"
 import { Instance } from "../../src/project/instance"
 import { Identifier } from "../../src/id/id"
+import { MessageID } from "../../src/session/schema"
 import { tmpdir } from "../fixture/fixture"
 
 const projectRoot = path.join(__dirname, "../..")
@@ -24,7 +25,7 @@ describe("revert + compact workflow", () => {
 
         // Create a user message
         const userMsg1 = await Session.updateMessage({
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "user",
           sessionID,
           agent: "default",
@@ -48,7 +49,7 @@ describe("revert + compact workflow", () => {
 
         // Create an assistant response message
         const assistantMsg1: MessageV2.Assistant = {
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "assistant",
           sessionID,
           mode: "default",
@@ -85,7 +86,7 @@ describe("revert + compact workflow", () => {
 
         // Create another user message
         const userMsg2 = await Session.updateMessage({
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "user",
           sessionID,
           agent: "default",
@@ -108,7 +109,7 @@ describe("revert + compact workflow", () => {
 
         // Create another assistant response
         const assistantMsg2: MessageV2.Assistant = {
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "assistant",
           sessionID,
           mode: "default",
@@ -200,7 +201,7 @@ describe("revert + compact workflow", () => {
 
         // Create initial messages
         const userMsg = await Session.updateMessage({
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "user",
           sessionID,
           agent: "default",
@@ -222,7 +223,7 @@ describe("revert + compact workflow", () => {
         })
 
         const assistantMsg: MessageV2.Assistant = {
-          id: Identifier.ascending("message"),
+          id: MessageID.ascending(),
           role: "assistant",
           sessionID,
           mode: "default",

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

@@ -6,6 +6,7 @@ import { Log } from "../../src/util/log"
 import { Instance } from "../../src/project/instance"
 import { MessageV2 } from "../../src/session/message-v2"
 import { Identifier } from "../../src/id/id"
+import { MessageID } from "../../src/session/schema"
 
 const projectRoot = path.join(__dirname, "../..")
 Log.init({ print: false })
@@ -81,7 +82,7 @@ describe("step-finish token propagation via Bus event", () => {
         fn: async () => {
           const session = await Session.create({})
 
-          const messageID = Identifier.ascending("message")
+          const messageID = MessageID.ascending()
           await Session.updateMessage({
             id: messageID,
             sessionID: session.id,

+ 5 - 5
packages/opencode/test/session/structured-output.test.ts

@@ -1,7 +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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 describe("structured-output.OutputFormat", () => {
   test("parses text format", () => {
@@ -96,7 +96,7 @@ describe("structured-output.StructuredOutputError", () => {
 describe("structured-output.UserMessage", () => {
   test("user message accepts outputFormat", () => {
     const result = MessageV2.User.safeParse({
-      id: "test-id",
+      id: MessageID.ascending(),
       sessionID: SessionID.descending(),
       role: "user",
       time: { created: Date.now() },
@@ -112,7 +112,7 @@ describe("structured-output.UserMessage", () => {
 
   test("user message works without outputFormat (optional)", () => {
     const result = MessageV2.User.safeParse({
-      id: "test-id",
+      id: MessageID.ascending(),
       sessionID: SessionID.descending(),
       role: "user",
       time: { created: Date.now() },
@@ -125,10 +125,10 @@ describe("structured-output.UserMessage", () => {
 
 describe("structured-output.AssistantMessage", () => {
   const baseAssistantMessage = {
-    id: "test-id",
+    id: MessageID.ascending(),
     sessionID: SessionID.descending(),
     role: "assistant" as const,
-    parentID: "parent-id",
+    parentID: MessageID.ascending(),
     modelID: "claude-3",
     providerID: "anthropic",
     mode: "default",

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

@@ -11,7 +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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 // Test fixtures
 const fixtures = {
@@ -255,7 +255,7 @@ describe("JSON to SQLite migration", () => {
     const db = drizzle({ client: sqlite })
     const messages = db.select().from(MessageTable).all()
     expect(messages.length).toBe(1)
-    expect(messages[0].id).toBe("msg_test789ghi")
+    expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
 
     const parts = db.select().from(PartTable).all()
     expect(parts.length).toBe(1)
@@ -295,7 +295,7 @@ describe("JSON to SQLite migration", () => {
     const db = drizzle({ client: sqlite })
     const messages = db.select().from(MessageTable).all()
     expect(messages.length).toBe(1)
-    expect(messages[0].id).toBe("msg_test789ghi")
+    expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
     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 +303,7 @@ describe("JSON to SQLite migration", () => {
     const parts = db.select().from(PartTable).all()
     expect(parts.length).toBe(1)
     expect(parts[0].id).toBe("prt_testabc123")
-    expect(parts[0].message_id).toBe("msg_test789ghi")
+    expect(parts[0].message_id).toBe(MessageID.make("msg_test789ghi"))
     expect(parts[0].session_id).toBe(SessionID.make("ses_test456def"))
     expect(parts[0].data).not.toHaveProperty("id")
     expect(parts[0].data).not.toHaveProperty("messageID")
@@ -336,7 +336,7 @@ describe("JSON to SQLite migration", () => {
     const db = drizzle({ client: sqlite })
     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].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
     expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
   })
 
@@ -375,7 +375,7 @@ describe("JSON to SQLite migration", () => {
     const parts = db.select().from(PartTable).all()
     expect(parts.length).toBe(1)
     expect(parts[0].id).toBe("prt_from_filename") // Uses filename, not JSON id
-    expect(parts[0].message_id).toBe("msg_realmsgid") // Uses parent dir, not JSON messageID
+    expect(parts[0].message_id).toBe(MessageID.make("msg_realmsgid")) // Uses parent dir, not JSON messageID
   })
 
   test("skips orphaned sessions (no parent project)", async () => {

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

@@ -4,11 +4,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const baseCtx = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -7,11 +7,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -5,11 +5,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {
   sessionID: SessionID.make("ses_test-edit-session"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -4,11 +4,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const baseCtx: Omit<Tool.Context, "ask"> = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -3,11 +3,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -2,11 +2,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {
   sessionID: SessionID.make("ses_test-session"),
-  messageID: "test-message",
+  messageID: MessageID.make("test-message"),
   callID: "test-call",
   agent: "test-agent",
   abort: AbortSignal.any([]),

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

@@ -6,13 +6,13 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 
 const ctx = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -6,11 +6,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const baseCtx: Omit<Tool.Context, "ask"> = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -2,13 +2,13 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const projectRoot = path.join(import.meta.dir, "../..")
 
 const ctx = {
   sessionID: SessionID.make("ses_test"),
-  messageID: "message",
+  messageID: MessageID.make("message"),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),

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

@@ -4,11 +4,11 @@ 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"
+import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {
   sessionID: SessionID.make("ses_test-write-session"),
-  messageID: "",
+  messageID: MessageID.make(""),
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),