From e6a17d32ec57b174cf981e11aa5ccd7f599a1d09 Mon Sep 17 00:00:00 2001 From: klly14 Date: Wed, 6 May 2026 14:26:57 -0400 Subject: [PATCH 1/2] fix(session): server always generates message ID, store client-provided ID as clientMessageID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a client passes a custom messageID via prompt_async, OpenCode was using it directly as the user message ID. This triggered a different internal setup path that ran asynchronously, creating a race condition where streaming chunks from LiteLLM (openai-compatible provider) arrived before the text part container was created — causing "text part chatcmpl-... not found" errors and dropped chunks. Fix: always generate the message ID server-side using MessageID.ascending(). Store the client-provided messageID as clientMessageID on the UserMessage for external correlation only. This matches the behaviour when no messageID is provided, ensuring the eager setup path is always used regardless of whether the client sends an ID. --- packages/opencode/src/session/message-v2.ts | 1 + packages/opencode/src/session/prompt.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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) => From 780df22eb1988fe89f2f55c0f0fafb92fde31d68 Mon Sep 17 00:00:00 2001 From: klly14 Date: Wed, 6 May 2026 14:40:33 -0400 Subject: [PATCH 2/2] test(session): verify clientMessageID is stored separately from server-generated message id Add three tests to prompt.test.ts that cover the fix in prompt.ts where input.messageID is stored as clientMessageID instead of being used as the canonical message id: - When a caller provides messageID, the stored user message uses a server-generated id (not the caller-provided one) - The caller-provided messageID is preserved in clientMessageID - The assistant's parentID points to the server-generated user message id, not the client-provided one - When no messageID is provided, clientMessageID is undefined --- packages/opencode/test/session/prompt.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) 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 }, + ), +)