Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })))
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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() },
Expand All @@ -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) =>
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
),
)
Loading