diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 237fb527c078..540f079aef2e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -397,6 +397,7 @@ export const User = Schema.Struct({ }), system: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + clientMessageID: Schema.optional(Schema.String), }) .annotate({ identifier: "UserMessage" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c438366c..e3a145e1f36a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -758,12 +758,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) const userMsg: MessageV2.User = { - id: input.messageID ?? MessageID.ascending(), + id: MessageID.ascending(), sessionID: input.sessionID, time: { created: Date.now() }, role: "user", agent: input.agent, model: { providerID: model.providerID, modelID: model.modelID }, + clientMessageID: input.messageID, } yield* sessions.updateMessage(userMsg) const userPart: MessageV2.Part = { @@ -940,7 +941,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) const info: MessageV2.User = { - id: input.messageID ?? MessageID.ascending(), + id: MessageID.ascending(), role: "user", sessionID: input.sessionID, time: { created: Date.now() }, @@ -953,6 +954,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, system: input.system, format: input.format, + clientMessageID: input.messageID, } const current = Database.use((db) => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index c5170f346492..6505ea60445c 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2059,3 +2059,107 @@ it.live( ), 30_000, ) + +// clientMessageID tests — verifying that server always generates the canonical message ID +// and stores any caller-provided messageID as clientMessageID only. + +it.live("prompt stores caller-provided messageID as clientMessageID, not as the message id", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "clientMessageID test", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const clientID = MessageID.ascending() + + yield* llm.text("response") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + messageID: clientID, + parts: [{ type: "text", text: "hello" }], + }) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const userMsg = msgs.find((m) => m.info.role === "user") + + expect(userMsg).toBeDefined() + if (userMsg && userMsg.info.role === "user") { + // The stored id must be a fresh server-generated ID, not the caller-provided one + expect(userMsg.info.id).not.toBe(clientID) + // The caller-provided ID is preserved in clientMessageID + expect(userMsg.info.clientMessageID).toBe(clientID) + } + }), + { git: true, config: providerCfg }, + ), +) + +it.live("assistant parentID points to server-generated user message id when messageID is provided", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "parentID test", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const clientID = MessageID.ascending() + + yield* llm.text("response") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + messageID: clientID, + parts: [{ type: "text", text: "hello" }], + }) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const userMsg = msgs.find((m) => m.info.role === "user") + const assistantMsg = msgs.find((m) => m.info.role === "assistant") + + expect(userMsg).toBeDefined() + expect(assistantMsg).toBeDefined() + if (userMsg && assistantMsg && assistantMsg.info.role === "assistant") { + // Assistant must reference the server-generated user message id + expect(assistantMsg.info.parentID).toBe(userMsg.info.id) + // And that id is NOT the client-provided one + expect(assistantMsg.info.parentID).not.toBe(clientID) + } + }), + { git: true, config: providerCfg }, + ), +) + +it.live("prompt without messageID still creates user message with no clientMessageID", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "no clientMessageID test", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* llm.text("response") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "hello" }], + }) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const userMsg = msgs.find((m) => m.info.role === "user") + + expect(userMsg).toBeDefined() + if (userMsg && userMsg.info.role === "user") { + expect(userMsg.info.clientMessageID).toBeUndefined() + } + }), + { git: true, config: providerCfg }, + ), +)