Skip to content

Commit e35a413

Browse files
authored
core: keep message part order stable when files resolve asynchronously (#13915)
1 parent 0e669b6 commit e35a413

File tree

2 files changed

+62
-25
lines changed

2 files changed

+62
-25
lines changed

packages/opencode/src/session/prompt.ts

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -974,17 +974,22 @@ export namespace SessionPrompt {
974974
}
975975
using _ = defer(() => InstructionPrompt.clear(info.id))
976976

977+
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
978+
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
979+
...part,
980+
id: part.id ?? Identifier.ascending("part"),
981+
})
982+
977983
const parts = await Promise.all(
978-
input.parts.map(async (part): Promise<MessageV2.Part[]> => {
984+
input.parts.map(async (part): Promise<Draft<MessageV2.Part>[]> => {
979985
if (part.type === "file") {
980986
// before checking the protocol we check if this is an mcp resource because it needs special handling
981987
if (part.source?.type === "resource") {
982988
const { clientName, uri } = part.source
983989
log.info("mcp resource", { clientName, uri, mime: part.mime })
984990

985-
const pieces: MessageV2.Part[] = [
991+
const pieces: Draft<MessageV2.Part>[] = [
986992
{
987-
id: Identifier.ascending("part"),
988993
messageID: info.id,
989994
sessionID: input.sessionID,
990995
type: "text",
@@ -1007,7 +1012,6 @@ export namespace SessionPrompt {
10071012
for (const content of contents) {
10081013
if ("text" in content && content.text) {
10091014
pieces.push({
1010-
id: Identifier.ascending("part"),
10111015
messageID: info.id,
10121016
sessionID: input.sessionID,
10131017
type: "text",
@@ -1018,7 +1022,6 @@ export namespace SessionPrompt {
10181022
// Handle binary content if needed
10191023
const mimeType = "mimeType" in content ? content.mimeType : part.mime
10201024
pieces.push({
1021-
id: Identifier.ascending("part"),
10221025
messageID: info.id,
10231026
sessionID: input.sessionID,
10241027
type: "text",
@@ -1030,15 +1033,13 @@ export namespace SessionPrompt {
10301033

10311034
pieces.push({
10321035
...part,
1033-
id: part.id ?? Identifier.ascending("part"),
10341036
messageID: info.id,
10351037
sessionID: input.sessionID,
10361038
})
10371039
} catch (error: unknown) {
10381040
log.error("failed to read MCP resource", { error, clientName, uri })
10391041
const message = error instanceof Error ? error.message : String(error)
10401042
pieces.push({
1041-
id: Identifier.ascending("part"),
10421043
messageID: info.id,
10431044
sessionID: input.sessionID,
10441045
type: "text",
@@ -1055,15 +1056,13 @@ export namespace SessionPrompt {
10551056
if (part.mime === "text/plain") {
10561057
return [
10571058
{
1058-
id: Identifier.ascending("part"),
10591059
messageID: info.id,
10601060
sessionID: input.sessionID,
10611061
type: "text",
10621062
synthetic: true,
10631063
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
10641064
},
10651065
{
1066-
id: Identifier.ascending("part"),
10671066
messageID: info.id,
10681067
sessionID: input.sessionID,
10691068
type: "text",
@@ -1072,7 +1071,6 @@ export namespace SessionPrompt {
10721071
},
10731072
{
10741073
...part,
1075-
id: part.id ?? Identifier.ascending("part"),
10761074
messageID: info.id,
10771075
sessionID: input.sessionID,
10781076
},
@@ -1129,9 +1127,8 @@ export namespace SessionPrompt {
11291127
}
11301128
const args = { filePath: filepath, offset, limit }
11311129

1132-
const pieces: MessageV2.Part[] = [
1130+
const pieces: Draft<MessageV2.Part>[] = [
11331131
{
1134-
id: Identifier.ascending("part"),
11351132
messageID: info.id,
11361133
sessionID: input.sessionID,
11371134
type: "text",
@@ -1155,7 +1152,6 @@ export namespace SessionPrompt {
11551152
}
11561153
const result = await t.execute(args, readCtx)
11571154
pieces.push({
1158-
id: Identifier.ascending("part"),
11591155
messageID: info.id,
11601156
sessionID: input.sessionID,
11611157
type: "text",
@@ -1166,7 +1162,6 @@ export namespace SessionPrompt {
11661162
pieces.push(
11671163
...result.attachments.map((attachment) => ({
11681164
...attachment,
1169-
id: Identifier.ascending("part"),
11701165
synthetic: true,
11711166
filename: attachment.filename ?? part.filename,
11721167
messageID: info.id,
@@ -1176,7 +1171,6 @@ export namespace SessionPrompt {
11761171
} else {
11771172
pieces.push({
11781173
...part,
1179-
id: part.id ?? Identifier.ascending("part"),
11801174
messageID: info.id,
11811175
sessionID: input.sessionID,
11821176
})
@@ -1192,7 +1186,6 @@ export namespace SessionPrompt {
11921186
}).toObject(),
11931187
})
11941188
pieces.push({
1195-
id: Identifier.ascending("part"),
11961189
messageID: info.id,
11971190
sessionID: input.sessionID,
11981191
type: "text",
@@ -1219,15 +1212,13 @@ export namespace SessionPrompt {
12191212
const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
12201213
return [
12211214
{
1222-
id: Identifier.ascending("part"),
12231215
messageID: info.id,
12241216
sessionID: input.sessionID,
12251217
type: "text",
12261218
synthetic: true,
12271219
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
12281220
},
12291221
{
1230-
id: Identifier.ascending("part"),
12311222
messageID: info.id,
12321223
sessionID: input.sessionID,
12331224
type: "text",
@@ -1236,7 +1227,6 @@ export namespace SessionPrompt {
12361227
},
12371228
{
12381229
...part,
1239-
id: part.id ?? Identifier.ascending("part"),
12401230
messageID: info.id,
12411231
sessionID: input.sessionID,
12421232
},
@@ -1247,15 +1237,14 @@ export namespace SessionPrompt {
12471237
FileTime.read(input.sessionID, filepath)
12481238
return [
12491239
{
1250-
id: Identifier.ascending("part"),
12511240
messageID: info.id,
12521241
sessionID: input.sessionID,
12531242
type: "text",
12541243
text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
12551244
synthetic: true,
12561245
},
12571246
{
1258-
id: part.id ?? Identifier.ascending("part"),
1247+
id: part.id,
12591248
messageID: info.id,
12601249
sessionID: input.sessionID,
12611250
type: "file",
@@ -1274,13 +1263,11 @@ export namespace SessionPrompt {
12741263
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
12751264
return [
12761265
{
1277-
id: Identifier.ascending("part"),
12781266
...part,
12791267
messageID: info.id,
12801268
sessionID: input.sessionID,
12811269
},
12821270
{
1283-
id: Identifier.ascending("part"),
12841271
messageID: info.id,
12851272
sessionID: input.sessionID,
12861273
type: "text",
@@ -1297,14 +1284,13 @@ export namespace SessionPrompt {
12971284

12981285
return [
12991286
{
1300-
id: Identifier.ascending("part"),
13011287
...part,
13021288
messageID: info.id,
13031289
sessionID: input.sessionID,
13041290
},
13051291
]
13061292
}),
1307-
).then((x) => x.flat())
1293+
).then((x) => x.flat().map(assign))
13081294

13091295
await Plugin.trigger(
13101296
"chat.message",

packages/opencode/test/session/prompt-missing-file.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from "path"
22
import { describe, expect, test } from "bun:test"
33
import { Instance } from "../../src/project/instance"
44
import { Session } from "../../src/session"
5+
import { MessageV2 } from "../../src/session/message-v2"
56
import { SessionPrompt } from "../../src/session/prompt"
67
import { tmpdir } from "../fixture/fixture"
78

@@ -50,4 +51,54 @@ describe("session.prompt missing file", () => {
5051
},
5152
})
5253
})
54+
55+
test("keeps stored part order stable when file resolution is async", async () => {
56+
await using tmp = await tmpdir({
57+
git: true,
58+
config: {
59+
agent: {
60+
build: {
61+
model: "openai/gpt-5.2",
62+
},
63+
},
64+
},
65+
})
66+
67+
await Instance.provide({
68+
directory: tmp.path,
69+
fn: async () => {
70+
const session = await Session.create({})
71+
72+
const missing = path.join(tmp.path, "still-missing.ts")
73+
const msg = await SessionPrompt.prompt({
74+
sessionID: session.id,
75+
agent: "build",
76+
noReply: true,
77+
parts: [
78+
{
79+
type: "file",
80+
mime: "text/plain",
81+
url: `file://${missing}`,
82+
filename: "still-missing.ts",
83+
},
84+
{ type: "text", text: "after-file" },
85+
],
86+
})
87+
88+
if (msg.info.role !== "user") throw new Error("expected user message")
89+
90+
const stored = await MessageV2.get({
91+
sessionID: session.id,
92+
messageID: msg.info.id,
93+
})
94+
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
95+
96+
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
97+
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
98+
expect(text[2]).toBe("after-file")
99+
100+
await Session.remove(session.id)
101+
},
102+
})
103+
})
53104
})

0 commit comments

Comments
 (0)