core: keep message part order stable when files resolve asynchronously (#13915)
This commit is contained in:
@@ -974,17 +974,22 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
using _ = defer(() => InstructionPrompt.clear(info.id))
|
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(
|
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") {
|
if (part.type === "file") {
|
||||||
// before checking the protocol we check if this is an mcp resource because it needs special handling
|
// before checking the protocol we check if this is an mcp resource because it needs special handling
|
||||||
if (part.source?.type === "resource") {
|
if (part.source?.type === "resource") {
|
||||||
const { clientName, uri } = part.source
|
const { clientName, uri } = part.source
|
||||||
log.info("mcp resource", { clientName, uri, mime: part.mime })
|
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,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1007,7 +1012,6 @@ export namespace SessionPrompt {
|
|||||||
for (const content of contents) {
|
for (const content of contents) {
|
||||||
if ("text" in content && content.text) {
|
if ("text" in content && content.text) {
|
||||||
pieces.push({
|
pieces.push({
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1018,7 +1022,6 @@ export namespace SessionPrompt {
|
|||||||
// Handle binary content if needed
|
// Handle binary content if needed
|
||||||
const mimeType = "mimeType" in content ? content.mimeType : part.mime
|
const mimeType = "mimeType" in content ? content.mimeType : part.mime
|
||||||
pieces.push({
|
pieces.push({
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1030,7 +1033,6 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
pieces.push({
|
pieces.push({
|
||||||
...part,
|
...part,
|
||||||
id: part.id ?? Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
})
|
})
|
||||||
@@ -1038,7 +1040,6 @@ export namespace SessionPrompt {
|
|||||||
log.error("failed to read MCP resource", { error, clientName, uri })
|
log.error("failed to read MCP resource", { error, clientName, uri })
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
pieces.push({
|
pieces.push({
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1055,7 +1056,6 @@ export namespace SessionPrompt {
|
|||||||
if (part.mime === "text/plain") {
|
if (part.mime === "text/plain") {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1063,7 +1063,6 @@ export namespace SessionPrompt {
|
|||||||
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
|
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1072,7 +1071,6 @@ export namespace SessionPrompt {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
...part,
|
...part,
|
||||||
id: part.id ?? Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
},
|
},
|
||||||
@@ -1129,9 +1127,8 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
const args = { filePath: filepath, offset, limit }
|
const args = { filePath: filepath, offset, limit }
|
||||||
|
|
||||||
const pieces: MessageV2.Part[] = [
|
const pieces: Draft<MessageV2.Part>[] = [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1155,7 +1152,6 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
const result = await t.execute(args, readCtx)
|
const result = await t.execute(args, readCtx)
|
||||||
pieces.push({
|
pieces.push({
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1166,7 +1162,6 @@ export namespace SessionPrompt {
|
|||||||
pieces.push(
|
pieces.push(
|
||||||
...result.attachments.map((attachment) => ({
|
...result.attachments.map((attachment) => ({
|
||||||
...attachment,
|
...attachment,
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
filename: attachment.filename ?? part.filename,
|
filename: attachment.filename ?? part.filename,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
@@ -1176,7 +1171,6 @@ export namespace SessionPrompt {
|
|||||||
} else {
|
} else {
|
||||||
pieces.push({
|
pieces.push({
|
||||||
...part,
|
...part,
|
||||||
id: part.id ?? Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
})
|
})
|
||||||
@@ -1192,7 +1186,6 @@ export namespace SessionPrompt {
|
|||||||
}).toObject(),
|
}).toObject(),
|
||||||
})
|
})
|
||||||
pieces.push({
|
pieces.push({
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1219,7 +1212,6 @@ export namespace SessionPrompt {
|
|||||||
const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
|
const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1227,7 +1219,6 @@ export namespace SessionPrompt {
|
|||||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1236,7 +1227,6 @@ export namespace SessionPrompt {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
...part,
|
...part,
|
||||||
id: part.id ?? Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
},
|
},
|
||||||
@@ -1247,7 +1237,6 @@ export namespace SessionPrompt {
|
|||||||
FileTime.read(input.sessionID, filepath)
|
FileTime.read(input.sessionID, filepath)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1255,7 +1244,7 @@ export namespace SessionPrompt {
|
|||||||
synthetic: true,
|
synthetic: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: part.id ?? Identifier.ascending("part"),
|
id: part.id,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "file",
|
type: "file",
|
||||||
@@ -1274,13 +1263,11 @@ export namespace SessionPrompt {
|
|||||||
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
...part,
|
...part,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1297,14 +1284,13 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
...part,
|
...part,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
).then((x) => x.flat())
|
).then((x) => x.flat().map(assign))
|
||||||
|
|
||||||
await Plugin.trigger(
|
await Plugin.trigger(
|
||||||
"chat.message",
|
"chat.message",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from "path"
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Session } from "../../src/session"
|
import { Session } from "../../src/session"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user