summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-02-16 18:45:11 -0600
committerGitHub <[email protected]>2026-02-16 18:45:11 -0600
commite35a4131d00729b9ef75ca86b03e70b656f00e2f (patch)
treed64cd4f6668c511858b800053b5b4d2eb4aaa93c
parent0e669b6016526d8966aae6ef548140765c93be9d (diff)
downloadopencode-e35a4131d00729b9ef75ca86b03e70b656f00e2f.tar.gz
opencode-e35a4131d00729b9ef75ca86b03e70b656f00e2f.zip
core: keep message part order stable when files resolve asynchronously (#13915)
-rw-r--r--packages/opencode/src/session/prompt.ts36
-rw-r--r--packages/opencode/test/session/prompt-missing-file.test.ts51
2 files changed, 62 insertions, 25 deletions
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 5890a4a73..43ad9a09d 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -974,17 +974,22 @@ export namespace SessionPrompt {
}
using _ = defer(() => InstructionPrompt.clear(info.id))
+ 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"),
+ })
+
const parts = await Promise.all(
- input.parts.map(async (part): Promise<MessageV2.Part[]> => {
+ input.parts.map(async (part): Promise<Draft<MessageV2.Part>[]> => {
if (part.type === "file") {
// before checking the protocol we check if this is an mcp resource because it needs special handling
if (part.source?.type === "resource") {
const { clientName, uri } = part.source
log.info("mcp resource", { clientName, uri, mime: part.mime })
- const pieces: MessageV2.Part[] = [
+ const pieces: Draft<MessageV2.Part>[] = [
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1007,7 +1012,6 @@ export namespace SessionPrompt {
for (const content of contents) {
if ("text" in content && content.text) {
pieces.push({
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1018,7 +1022,6 @@ export namespace SessionPrompt {
// Handle binary content if needed
const mimeType = "mimeType" in content ? content.mimeType : part.mime
pieces.push({
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1030,7 +1033,6 @@ export namespace SessionPrompt {
pieces.push({
...part,
- id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
})
@@ -1038,7 +1040,6 @@ export namespace SessionPrompt {
log.error("failed to read MCP resource", { error, clientName, uri })
const message = error instanceof Error ? error.message : String(error)
pieces.push({
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1055,7 +1056,6 @@ export namespace SessionPrompt {
if (part.mime === "text/plain") {
return [
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1063,7 +1063,6 @@ export namespace SessionPrompt {
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
},
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1072,7 +1071,6 @@ export namespace SessionPrompt {
},
{
...part,
- id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
},
@@ -1129,9 +1127,8 @@ export namespace SessionPrompt {
}
const args = { filePath: filepath, offset, limit }
- const pieces: MessageV2.Part[] = [
+ const pieces: Draft<MessageV2.Part>[] = [
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1155,7 +1152,6 @@ export namespace SessionPrompt {
}
const result = await t.execute(args, readCtx)
pieces.push({
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1166,7 +1162,6 @@ export namespace SessionPrompt {
pieces.push(
...result.attachments.map((attachment) => ({
...attachment,
- id: Identifier.ascending("part"),
synthetic: true,
filename: attachment.filename ?? part.filename,
messageID: info.id,
@@ -1176,7 +1171,6 @@ export namespace SessionPrompt {
} else {
pieces.push({
...part,
- id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
})
@@ -1192,7 +1186,6 @@ export namespace SessionPrompt {
}).toObject(),
})
pieces.push({
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1219,7 +1212,6 @@ export namespace SessionPrompt {
const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
return [
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1227,7 +1219,6 @@ export namespace SessionPrompt {
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1236,7 +1227,6 @@ export namespace SessionPrompt {
},
{
...part,
- id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
},
@@ -1247,7 +1237,6 @@ export namespace SessionPrompt {
FileTime.read(input.sessionID, filepath)
return [
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1255,7 +1244,7 @@ export namespace SessionPrompt {
synthetic: true,
},
{
- id: part.id ?? Identifier.ascending("part"),
+ id: part.id,
messageID: info.id,
sessionID: input.sessionID,
type: "file",
@@ -1274,13 +1263,11 @@ export namespace SessionPrompt {
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
return [
{
- id: Identifier.ascending("part"),
...part,
messageID: info.id,
sessionID: input.sessionID,
},
{
- id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1297,14 +1284,13 @@ export namespace SessionPrompt {
return [
{
- id: Identifier.ascending("part"),
...part,
messageID: info.id,
sessionID: input.sessionID,
},
]
}),
- ).then((x) => x.flat())
+ ).then((x) => x.flat().map(assign))
await Plugin.trigger(
"chat.message",
diff --git a/packages/opencode/test/session/prompt-missing-file.test.ts b/packages/opencode/test/session/prompt-missing-file.test.ts
index 081847c67..c3f52f56c 100644
--- a/packages/opencode/test/session/prompt-missing-file.test.ts
+++ b/packages/opencode/test/session/prompt-missing-file.test.ts
@@ -2,6 +2,7 @@ import path from "path"
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
+import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
@@ -50,4 +51,54 @@ describe("session.prompt missing file", () => {
},
})
})
+
+ test("keeps stored part order stable when file resolution is async", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ config: {
+ agent: {
+ build: {
+ model: "openai/gpt-5.2",
+ },
+ },
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await Session.create({})
+
+ const missing = path.join(tmp.path, "still-missing.ts")
+ const msg = await SessionPrompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ parts: [
+ {
+ type: "file",
+ mime: "text/plain",
+ url: `file://${missing}`,
+ filename: "still-missing.ts",
+ },
+ { type: "text", text: "after-file" },
+ ],
+ })
+
+ if (msg.info.role !== "user") throw new Error("expected user message")
+
+ const stored = await MessageV2.get({
+ sessionID: session.id,
+ messageID: msg.info.id,
+ })
+ const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
+
+ expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
+ expect(text[1]?.includes("Read tool failed to read")).toBe(true)
+ expect(text[2]).toBe("after-file")
+
+ await Session.remove(session.id)
+ },
+ })
+ })
})