Selaa lähdekoodia

feat(id): brand PartID through Drizzle and Zod schemas (#16966)

Kit Langton 1 kuukausi sitten
vanhempi
sitoutus
090f636354

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

@@ -11,8 +11,7 @@ const seed = async () => {
   const { Instance } = await import("../src/project/instance")
   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 { MessageID, PartID } = await import("../src/session/schema")
   const { Project } = await import("../src/project/project")
 
   await Instance.provide({
@@ -21,7 +20,7 @@ const seed = async () => {
     fn: async () => {
       const session = await Session.create({ title })
       const messageID = MessageID.ascending()
-      const partID = Identifier.descending("part")
+      const partID = PartID.ascending()
       const message = {
         id: messageID,
         sessionID: session.id,

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

@@ -4,8 +4,7 @@ import { Agent } from "../../../agent/agent"
 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 { MessageID, PartID } from "../../../session/schema"
 import { ToolRegistry } from "../../../tool/registry"
 import { Instance } from "../../../project/instance"
 import { PermissionNext } from "../../../permission/next"
@@ -151,7 +150,7 @@ async function createToolContext(agent: Agent.Info) {
   return {
     sessionID: session.id,
     messageID,
-    callID: Identifier.ascending("part"),
+    callID: PartID.ascending(),
     agent: agent.name,
     abort: new AbortController().signal,
     messages: [],

+ 4 - 5
packages/opencode/src/cli/cmd/github.ts

@@ -23,8 +23,7 @@ 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 { MessageID } from "../../session/schema"
+import { MessageID, PartID } from "../../session/schema"
 import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
 import { MessageV2 } from "../../session/message-v2"
@@ -945,13 +944,13 @@ export const GithubRunCommand = cmd({
           // agent is omitted - server will use default_agent from config or fall back to "build"
           parts: [
             {
-              id: Identifier.ascending("part"),
+              id: PartID.ascending(),
               type: "text",
               text: message,
             },
             ...files.flatMap((f) => [
               {
-                id: Identifier.ascending("part"),
+                id: PartID.ascending(),
                 type: "file" as const,
                 mime: f.mime,
                 url: `data:${f.mime};base64,${f.content}`,
@@ -999,7 +998,7 @@ export const GithubRunCommand = cmd({
           tools: { "*": false }, // Disable all tools to force text response
           parts: [
             {
-              id: Identifier.ascending("part"),
+              id: PartID.ascending(),
               type: "text",
               text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
             },

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

@@ -1,7 +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, MessageID } from "../../session/schema"
+import { SessionID, MessageID, PartID } from "../../session/schema"
 import { WorkspaceID } from "../../control-plane/schema"
 import { cmd } from "./cmd"
 import { bootstrap } from "../bootstrap"
@@ -161,7 +161,11 @@ export const ImportCommand = cmd({
         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) }
+          ? {
+              ...exportData.info.revert,
+              messageID: MessageID.make(exportData.info.revert.messageID),
+              partID: exportData.info.revert.partID ? PartID.make(exportData.info.revert.partID) : undefined,
+            }
           : undefined,
       })
       Database.use((db) =>
@@ -193,7 +197,7 @@ export const ImportCommand = cmd({
             db
               .insert(PartTable)
               .values({
-                id: part.id,
+                id: PartID.make(part.id),
                 message_id: MessageID.make(msg.info.id),
                 session_id: row.id,
                 data: partData,

+ 4 - 5
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -9,8 +9,7 @@ import { EmptyBorder } from "@tui/component/border"
 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 { MessageID, PartID } from "@/session/schema"
 import { createStore, produce } from "solid-js/store"
 import { useKeybind } from "@tui/context/keybind"
 import { usePromptHistory, type PromptInfo } from "./history"
@@ -625,7 +624,7 @@ export function Prompt(props: PromptProps) {
         parts: nonTextParts
           .filter((x) => x.type === "file")
           .map((x) => ({
-            id: Identifier.ascending("part"),
+            id: PartID.ascending(),
             ...x,
           })),
       })
@@ -640,12 +639,12 @@ export function Prompt(props: PromptProps) {
           variant,
           parts: [
             {
-              id: Identifier.ascending("part"),
+              id: PartID.ascending(),
               type: "text",
               text: inputText,
             },
             ...nonTextParts.map((x) => ({
-              id: Identifier.ascending("part"),
+              id: PartID.ascending(),
               ...x,
             })),
           ],

+ 3 - 3
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, MessageID } from "@/session/schema"
+import { SessionID, MessageID, PartID } from "@/session/schema"
 import z from "zod"
 import { Session } from "../../session"
 import { MessageV2 } from "../../session/message-v2"
@@ -677,7 +677,7 @@ export const SessionRoutes = lazy(() =>
         z.object({
           sessionID: SessionID.zod,
           messageID: MessageID.zod,
-          partID: z.string(),
+          partID: PartID.zod,
         }),
       ),
       async (c) => {
@@ -712,7 +712,7 @@ export const SessionRoutes = lazy(() =>
         z.object({
           sessionID: SessionID.zod,
           messageID: MessageID.zod,
-          partID: z.string(),
+          partID: PartID.zod,
         }),
       ),
       validator("json", MessageV2.Part),

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

@@ -1,8 +1,7 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { Session } from "."
-import { Identifier } from "../id/id"
-import { SessionID, MessageID } from "./schema"
+import { SessionID, MessageID, PartID } from "./schema"
 import { Instance } from "../project/instance"
 import { Provider } from "../provider/provider"
 import { MessageV2 } from "./message-v2"
@@ -256,7 +255,7 @@ When constructing the summary, try to stick to this template:
               : part
           await Session.updatePart({
             ...replayPart,
-            id: Identifier.ascending("part"),
+            id: PartID.ascending(),
             messageID: replayMsg.id,
             sessionID: input.sessionID,
           })
@@ -276,7 +275,7 @@ When constructing the summary, try to stick to this template:
             : "") +
           "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
         await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: continueMsg.id,
           sessionID: input.sessionID,
           type: "text",
@@ -317,7 +316,7 @@ When constructing the summary, try to stick to this template:
         },
       })
       await Session.updatePart({
-        id: Identifier.ascending("part"),
+        id: PartID.ascending(),
         messageID: msg.id,
         sessionID: msg.sessionID,
         type: "compaction",

+ 5 - 6
packages/opencode/src/session/index.ts

@@ -7,7 +7,6 @@ import z from "zod"
 import { type ProviderMetadata } from "ai"
 import { Config } from "../config/config"
 import { Flag } from "../flag/flag"
-import { Identifier } from "../id/id"
 import { Installation } from "../installation"
 
 import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
@@ -25,7 +24,7 @@ import { Snapshot } from "@/snapshot"
 import { WorkspaceContext } from "../control-plane/workspace-context"
 import { ProjectID } from "../project/schema"
 import { WorkspaceID } from "../control-plane/schema"
-import { SessionID, MessageID } from "./schema"
+import { SessionID, MessageID, PartID } from "./schema"
 
 import type { Provider } from "@/provider/provider"
 import { PermissionNext } from "@/permission/next"
@@ -152,7 +151,7 @@ export namespace Session {
       revert: z
         .object({
           messageID: MessageID.zod,
-          partID: z.string().optional(),
+          partID: PartID.zod.optional(),
           snapshot: z.string().optional(),
           diff: z.string().optional(),
         })
@@ -269,7 +268,7 @@ export namespace Session {
         for (const part of msg.parts) {
           await updatePart({
             ...part,
-            id: Identifier.ascending("part"),
+            id: PartID.ascending(),
             messageID: cloned.id,
             sessionID: session.id,
           })
@@ -731,7 +730,7 @@ export namespace Session {
     z.object({
       sessionID: SessionID.zod,
       messageID: MessageID.zod,
-      partID: Identifier.schema("part"),
+      partID: PartID.zod,
     }),
     async (input) => {
       Database.use((db) => {
@@ -779,7 +778,7 @@ export namespace Session {
     z.object({
       sessionID: SessionID.zod,
       messageID: MessageID.zod,
-      partID: z.string(),
+      partID: PartID.zod,
       field: z.string(),
       delta: z.string(),
     }),

+ 4 - 4
packages/opencode/src/session/message-v2.ts

@@ -1,5 +1,5 @@
 import { BusEvent } from "@/bus/bus-event"
-import { SessionID, MessageID } from "./schema"
+import { SessionID, MessageID, PartID } from "./schema"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
@@ -78,7 +78,7 @@ export namespace MessageV2 {
   export type OutputFormat = z.infer<typeof Format>
 
   const PartBase = z.object({
-    id: z.string(),
+    id: PartID.zod,
     sessionID: SessionID.zod,
     messageID: MessageID.zod,
   })
@@ -472,7 +472,7 @@ export namespace MessageV2 {
       z.object({
         sessionID: SessionID.zod,
         messageID: MessageID.zod,
-        partID: z.string(),
+        partID: PartID.zod,
         field: z.string(),
         delta: z.string(),
       }),
@@ -482,7 +482,7 @@ export namespace MessageV2 {
       z.object({
         sessionID: SessionID.zod,
         messageID: MessageID.zod,
-        partID: z.string(),
+        partID: PartID.zod,
       }),
     ),
   }

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

@@ -1,6 +1,5 @@
 import { MessageV2 } from "./message-v2"
 import { Log } from "@/util/log"
-import { Identifier } from "@/id/id"
 import { Session } from "."
 import { Agent } from "@/agent/agent"
 import { Snapshot } from "@/snapshot"
@@ -15,6 +14,7 @@ import { Config } from "@/config/config"
 import { SessionCompaction } from "./compaction"
 import { PermissionNext } from "@/permission/next"
 import { Question } from "@/question"
+import { PartID } from "./schema"
 import type { SessionID, MessageID } from "./schema"
 
 export namespace SessionProcessor {
@@ -65,7 +65,7 @@ export namespace SessionProcessor {
                     continue
                   }
                   const reasoningPart = {
-                    id: Identifier.ascending("part"),
+                    id: PartID.ascending(),
                     messageID: input.assistantMessage.id,
                     sessionID: input.assistantMessage.sessionID,
                     type: "reasoning" as const,
@@ -111,7 +111,7 @@ export namespace SessionProcessor {
 
                 case "tool-input-start":
                   const part = await Session.updatePart({
-                    id: toolcalls[value.id]?.id ?? Identifier.ascending("part"),
+                    id: toolcalls[value.id]?.id ?? PartID.ascending(),
                     messageID: input.assistantMessage.id,
                     sessionID: input.assistantMessage.sessionID,
                     type: "tool",
@@ -234,7 +234,7 @@ export namespace SessionProcessor {
                 case "start-step":
                   snapshot = await Snapshot.track()
                   await Session.updatePart({
-                    id: Identifier.ascending("part"),
+                    id: PartID.ascending(),
                     messageID: input.assistantMessage.id,
                     sessionID: input.sessionID,
                     snapshot,
@@ -252,7 +252,7 @@ export namespace SessionProcessor {
                   input.assistantMessage.cost += usage.cost
                   input.assistantMessage.tokens = usage.tokens
                   await Session.updatePart({
-                    id: Identifier.ascending("part"),
+                    id: PartID.ascending(),
                     reason: value.finishReason,
                     snapshot: await Snapshot.track(),
                     messageID: input.assistantMessage.id,
@@ -266,7 +266,7 @@ export namespace SessionProcessor {
                     const patch = await Snapshot.patch(snapshot)
                     if (patch.files.length) {
                       await Session.updatePart({
-                        id: Identifier.ascending("part"),
+                        id: PartID.ascending(),
                         messageID: input.assistantMessage.id,
                         sessionID: input.sessionID,
                         type: "patch",
@@ -290,7 +290,7 @@ export namespace SessionProcessor {
 
                 case "text-start":
                   currentText = {
-                    id: Identifier.ascending("part"),
+                    id: PartID.ascending(),
                     messageID: input.assistantMessage.id,
                     sessionID: input.assistantMessage.sessionID,
                     type: "text",
@@ -389,7 +389,7 @@ export namespace SessionProcessor {
             const patch = await Snapshot.patch(snapshot)
             if (patch.files.length) {
               await Session.updatePart({
-                id: Identifier.ascending("part"),
+                id: PartID.ascending(),
                 messageID: input.assistantMessage.id,
                 sessionID: input.sessionID,
                 type: "patch",

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

@@ -3,8 +3,7 @@ import os from "os"
 import fs from "fs/promises"
 import z from "zod"
 import { Filesystem } from "../util/filesystem"
-import { Identifier } from "../id/id"
-import { SessionID, MessageID } from "./schema"
+import { SessionID, MessageID, PartID } from "./schema"
 import { MessageV2 } from "./message-v2"
 import { Log } from "../util/log"
 import { SessionRevert } from "./revert"
@@ -380,7 +379,7 @@ export namespace SessionPrompt {
           },
         })) as MessageV2.Assistant
         let part = (await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: assistantMessage.id,
           sessionID: assistantMessage.sessionID,
           type: "tool",
@@ -449,7 +448,7 @@ export namespace SessionPrompt {
         })
         const attachments = result?.attachments?.map((attachment) => ({
           ...attachment,
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           sessionID,
           messageID: assistantMessage.id,
         }))
@@ -515,7 +514,7 @@ export namespace SessionPrompt {
           }
           await Session.updateMessage(summaryUserMsg)
           await Session.updatePart({
-            id: Identifier.ascending("part"),
+            id: PartID.ascending(),
             messageID: summaryUserMsg.id,
             sessionID,
             type: "text",
@@ -814,7 +813,7 @@ export namespace SessionPrompt {
             ...result,
             attachments: result.attachments?.map((attachment) => ({
               ...attachment,
-              id: Identifier.ascending("part"),
+              id: PartID.ascending(),
               sessionID: ctx.sessionID,
               messageID: input.processor.message.id,
             })),
@@ -917,7 +916,7 @@ export namespace SessionPrompt {
           output: truncated.content,
           attachments: attachments.map((attachment) => ({
             ...attachment,
-            id: Identifier.ascending("part"),
+            id: PartID.ascending(),
             sessionID: ctx.sessionID,
             messageID: input.processor.message.id,
           })),
@@ -989,7 +988,7 @@ export namespace SessionPrompt {
     type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
     const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
       ...part,
-      id: part.id ?? Identifier.ascending("part"),
+      id: part.id ? PartID.make(part.id) : PartID.ascending(),
     })
 
     const parts = await Promise.all(
@@ -1335,7 +1334,7 @@ export namespace SessionPrompt {
     if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
       if (input.agent.name === "plan") {
         userMessage.parts.push({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: userMessage.info.id,
           sessionID: userMessage.info.sessionID,
           type: "text",
@@ -1346,7 +1345,7 @@ export namespace SessionPrompt {
       const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
       if (wasPlan && input.agent.name === "build") {
         userMessage.parts.push({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: userMessage.info.id,
           sessionID: userMessage.info.sessionID,
           type: "text",
@@ -1366,7 +1365,7 @@ export namespace SessionPrompt {
       const exists = await Filesystem.exists(plan)
       if (exists) {
         const part = await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: userMessage.info.id,
           sessionID: userMessage.info.sessionID,
           type: "text",
@@ -1385,7 +1384,7 @@ export namespace SessionPrompt {
       const exists = await Filesystem.exists(plan)
       if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
       const part = await Session.updatePart({
-        id: Identifier.ascending("part"),
+        id: PartID.ascending(),
         messageID: userMessage.info.id,
         sessionID: userMessage.info.sessionID,
         type: "text",
@@ -1520,7 +1519,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
     await Session.updateMessage(userMsg)
     const userPart: MessageV2.Part = {
       type: "text",
-      id: Identifier.ascending("part"),
+      id: PartID.ascending(),
       messageID: userMsg.id,
       sessionID: input.sessionID,
       text: "The following tool was executed by the user",
@@ -1555,7 +1554,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
     await Session.updateMessage(msg)
     const part: MessageV2.Part = {
       type: "tool",
-      id: Identifier.ascending("part"),
+      id: PartID.ascending(),
       messageID: msg.id,
       sessionID: input.sessionID,
       tool: "bash",

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

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

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

@@ -27,3 +27,15 @@ export const MessageID = messageIdSchema.pipe(
     zod: z.string().startsWith("msg").pipe(z.custom<MessageID>()),
   })),
 )
+
+const partIdSchema = Schema.String.pipe(Schema.brand("PartId"))
+
+export type PartID = typeof partIdSchema.Type
+
+export const PartID = partIdSchema.pipe(
+  withStatics((schema: typeof partIdSchema) => ({
+    make: (id: string) => schema.makeUnsafe(id),
+    ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("part", id)),
+    zod: z.string().startsWith("prt").pipe(z.custom<PartID>()),
+  })),
+)

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

@@ -4,7 +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, MessageID } from "./schema"
+import type { SessionID, MessageID, PartID } from "./schema"
 import type { WorkspaceID } from "../control-plane/schema"
 import { Timestamps } from "../storage/schema.sql"
 
@@ -30,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: MessageID; partID?: string; snapshot?: string; diff?: string }>(),
+    revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
     permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
     ...Timestamps,
     time_compacting: integer(),
@@ -60,7 +60,7 @@ export const MessageTable = sqliteTable(
 export const PartTable = sqliteTable(
   "part",
   {
-    id: text().primaryKey(),
+    id: text().$type<PartID>().primaryKey(),
     message_id: text()
       .$type<MessageID>()
       .notNull()

+ 4 - 4
packages/opencode/src/tool/batch.ts

@@ -31,7 +31,7 @@ export const BatchTool = Tool.define("batch", async () => {
     },
     async execute(params, ctx) {
       const { Session } = await import("../session")
-      const { Identifier } = await import("../id/id")
+      const { PartID } = await import("../session/schema")
 
       const toolCalls = params.tool_calls.slice(0, 25)
       const discardedCalls = params.tool_calls.slice(25)
@@ -42,7 +42,7 @@ export const BatchTool = Tool.define("batch", async () => {
 
       const executeCall = async (call: (typeof toolCalls)[0]) => {
         const callStartTime = Date.now()
-        const partID = Identifier.ascending("part")
+        const partID = PartID.ascending()
 
         try {
           if (DISALLOWED.has(call.tool)) {
@@ -79,7 +79,7 @@ export const BatchTool = Tool.define("batch", async () => {
           const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
           const attachments = result.attachments?.map((attachment) => ({
             ...attachment,
-            id: Identifier.ascending("part"),
+            id: PartID.ascending(),
             sessionID: ctx.sessionID,
             messageID: ctx.messageID,
           }))
@@ -134,7 +134,7 @@ export const BatchTool = Tool.define("batch", async () => {
       // Add discarded calls as errors
       const now = Date.now()
       for (const call of discardedCalls) {
-        const partID = Identifier.ascending("part")
+        const partID = PartID.ascending()
         await Session.updatePart({
           id: partID,
           messageID: ctx.messageID,

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

@@ -4,10 +4,9 @@ import { Tool } from "./tool"
 import { Question } from "../question"
 import { Session } from "../session"
 import { MessageV2 } from "../session/message-v2"
-import { Identifier } from "../id/id"
 import { Provider } from "../provider/provider"
 import { Instance } from "../project/instance"
-import { type SessionID, MessageID } from "../session/schema"
+import { type SessionID, MessageID, PartID } from "../session/schema"
 import EXIT_DESCRIPTION from "./plan-exit.txt"
 
 async function getLastModel(sessionID: SessionID) {
@@ -56,7 +55,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
     }
     await Session.updateMessage(userMsg)
     await Session.updatePart({
-      id: Identifier.ascending("part"),
+      id: PartID.ascending(),
       messageID: userMsg.id,
       sessionID: ctx.sessionID,
       type: "text",
@@ -114,7 +113,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
     }
     await Session.updateMessage(userMsg)
     await Session.updatePart({
-      id: Identifier.ascending("part"),
+      id: PartID.ascending(),
       messageID: userMsg.id,
       sessionID: ctx.sessionID,
       type: "text",

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

@@ -1,12 +1,12 @@
 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, MessageID } from "../../src/session/schema"
+import { SessionID, MessageID, PartID } from "../../src/session/schema"
 
 // Helper to create minimal valid parts
 function createTextPart(text: string): MessageV2.Part {
   return {
-    id: "1",
+    id: PartID.ascending(),
     sessionID: SessionID.make("s"),
     messageID: MessageID.make("m"),
     type: "text" as const,
@@ -16,7 +16,7 @@ function createTextPart(text: string): MessageV2.Part {
 
 function createReasoningPart(text: string): MessageV2.Part {
   return {
-    id: "1",
+    id: PartID.ascending(),
     sessionID: SessionID.make("s"),
     messageID: MessageID.make("m"),
     type: "reasoning" as const,
@@ -28,7 +28,7 @@ function createReasoningPart(text: string): MessageV2.Part {
 function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): MessageV2.Part {
   if (status === "completed") {
     return {
-      id: "1",
+      id: PartID.ascending(),
       sessionID: SessionID.make("s"),
       messageID: MessageID.make("m"),
       type: "tool" as const,
@@ -45,7 +45,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
     }
   }
   return {
-    id: "1",
+    id: PartID.ascending(),
     sessionID: SessionID.make("s"),
     messageID: MessageID.make("m"),
     type: "tool" as const,
@@ -61,7 +61,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
 
 function createStepStartPart(): MessageV2.Part {
   return {
-    id: "1",
+    id: PartID.ascending(),
     sessionID: SessionID.make("s"),
     messageID: MessageID.make("m"),
     type: "step-start" as const,
@@ -70,7 +70,7 @@ function createStepStartPart(): MessageV2.Part {
 
 function createStepFinishPart(): MessageV2.Part {
   return {
-    id: "1",
+    id: PartID.ascending(),
     sessionID: SessionID.make("s"),
     messageID: MessageID.make("m"),
     type: "step-finish" as const,

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

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

@@ -6,8 +6,7 @@ import { SessionCompaction } from "../../src/session/compaction"
 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 { MessageID, PartID } from "../../src/session/schema"
 import { tmpdir } from "../fixture/fixture"
 
 const projectRoot = path.join(__dirname, "../..")
@@ -40,7 +39,7 @@ describe("revert + compact workflow", () => {
 
         // Add a text part to the user message
         await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: userMsg1.id,
           sessionID,
           type: "text",
@@ -77,7 +76,7 @@ describe("revert + compact workflow", () => {
 
         // Add a text part to the assistant message
         await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: assistantMsg1.id,
           sessionID,
           type: "text",
@@ -100,7 +99,7 @@ describe("revert + compact workflow", () => {
         })
 
         await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: userMsg2.id,
           sessionID,
           type: "text",
@@ -136,7 +135,7 @@ describe("revert + compact workflow", () => {
         await Session.updateMessage(assistantMsg2)
 
         await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: assistantMsg2.id,
           sessionID,
           type: "text",
@@ -215,7 +214,7 @@ describe("revert + compact workflow", () => {
         })
 
         await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: userMsg.id,
           sessionID,
           type: "text",
@@ -250,7 +249,7 @@ describe("revert + compact workflow", () => {
         await Session.updateMessage(assistantMsg)
 
         await Session.updatePart({
-          id: Identifier.ascending("part"),
+          id: PartID.ascending(),
           messageID: assistantMsg.id,
           sessionID,
           type: "text",

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

@@ -5,8 +5,7 @@ import { Bus } from "../../src/bus"
 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"
+import { MessageID, PartID } from "../../src/session/schema"
 
 const projectRoot = path.join(__dirname, "../..")
 Log.init({ print: false })
@@ -108,7 +107,7 @@ describe("step-finish token propagation via Bus event", () => {
           }
 
           const partInput = {
-            id: Identifier.ascending("part"),
+            id: PartID.ascending(),
             messageID,
             sessionID: session.id,
             type: "step-finish" as const,

+ 4 - 4
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, MessageID } from "../../src/session/schema"
+import { SessionID, MessageID, PartID } from "../../src/session/schema"
 
 // Test fixtures
 const fixtures = {
@@ -259,7 +259,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].id).toBe(PartID.make("prt_testabc123"))
   })
 
   test("migrates legacy parts without ids in body", async () => {
@@ -302,7 +302,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].id).toBe(PartID.make("prt_testabc123"))
     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")
@@ -374,7 +374,7 @@ describe("JSON to SQLite migration", () => {
     const db = drizzle({ client: sqlite })
     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].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
     expect(parts[0].message_id).toBe(MessageID.make("msg_realmsgid")) // Uses parent dir, not JSON messageID
   })