fix(core): preserve prompt attachment order (#35921)

This commit is contained in:
Aiden Cline
2026-07-08 10:06:58 -05:00
committed by GitHub
parent 8b634e4a58
commit 05e8d5be73
2 changed files with 130 additions and 62 deletions
@@ -21,45 +21,50 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description }, metadata: file.description === undefined ? undefined : { description: file.description },
}) })
const textAttachment = (file: FileAttachment) => const textAttachment = (file: FileAttachment): ContentPart => ({
Message.make({ type: "text",
role: "user", text: `\n\n${[
content: [ `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`,
`Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, file.description === undefined ? undefined : `Description: ${file.description}`,
file.description === undefined ? undefined : `Description: ${file.description}`, "",
"", Buffer.from(file.data, "base64").toString("utf8"),
Buffer.from(file.data, "base64").toString("utf8"), ]
] .filter((line): line is string => line !== undefined)
.filter((line): line is string => line !== undefined) .join("\n")}`,
.join("\n"), metadata: {
metadata: { attachment: {
attachment: { source: file.source,
source: file.source, name: file.name,
name: file.name, description: file.description,
description: file.description,
},
}, },
}) },
})
const directoryAttachment = (file: FileAttachment) => const directoryAttachment = (file: FileAttachment): ContentPart => ({
Message.make({ type: "text",
role: "user", text: `\n\n${[
content: [ `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, file.description === undefined ? undefined : `Description: ${file.description}`,
file.description === undefined ? undefined : `Description: ${file.description}`, file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : "", file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), ]
] .filter((line): line is string => line !== undefined)
.filter((line): line is string => line !== undefined) .join("\n")}`,
.join("\n"), metadata: {
metadata: { attachment: {
attachment: { source: file.source,
source: file.source, name: file.name,
name: file.name, description: file.description,
description: file.description,
},
}, },
}) },
})
const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime)) return [media(file)]
return []
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
@@ -174,17 +179,16 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider
case "model-switched": case "model-switched":
return [] return []
case "user": case "user":
const files = message.files ?? [] const content = [
...(message.text === "" ? [] : [Message.text(message.text)]),
...(message.files ?? []).flatMap(attachmentContent),
]
if (content.length === 0) return []
return [ return [
...files.filter((file) => file.mime === "text/plain").map(textAttachment),
...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment),
Message.make({ Message.make({
id: message.id, id: message.id,
role: "user", role: "user",
content: [ content,
{ type: "text", text: message.text },
...files.filter((file) => imageMimes.has(file.mime)).map(media),
],
metadata: { metadata: {
...message.metadata, ...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}), ...(message.agents?.length ? { agents: message.agents } : {}),
@@ -144,7 +144,7 @@ Recent work
]) ])
}) })
test("lowers text attachments as separate user messages", () => { test("lowers text attachments after the prompt in one user message", () => {
const file = FileAttachment.make({ const file = FileAttachment.make({
data: Base64.make(Buffer.from("export const value = 1").toString("base64")), data: Base64.make(Buffer.from("export const value = 1").toString("base64")),
mime: "text/plain", mime: "text/plain",
@@ -164,21 +164,18 @@ Recent work
model, model,
) )
expect(messages).toHaveLength(2) expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({ expect(messages[0]).toMatchObject({
role: "user",
content: [
{
type: "text",
text: "Attached file: main.ts\n\nexport const value = 1",
},
],
metadata: { attachment: { source: file.source, name: "main.ts" } },
})
expect(messages[1]).toMatchObject({
id: id("user-text-file"), id: id("user-text-file"),
role: "user", role: "user",
content: [{ type: "text", text: "Review this file" }], content: [
{ type: "text", text: "Review this file" },
{
type: "text",
text: "\n\nAttached file: main.ts\n\nexport const value = 1",
metadata: { attachment: { source: file.source, name: "main.ts" } },
},
],
}) })
}) })
@@ -203,10 +200,11 @@ Recent work
model, model,
) )
expect(messages[0]?.content).toEqual([ expect(messages[0]?.content).toMatchObject([
{ type: "text", text: "Review this file" },
{ {
type: "text", type: "text",
text: "Attached file: inline.txt\n\ninline content", text: "\n\nAttached file: inline.txt\n\ninline content",
}, },
]) ])
}) })
@@ -231,13 +229,79 @@ Recent work
model, model,
) )
expect(messages).toHaveLength(2) expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({ expect(messages[0]).toMatchObject({
id: id("user-directory"),
role: "user", role: "user",
content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }], content: [
metadata: { attachment: { source: directory.source, name: "src/" } }, { type: "text", text: "Review this directory" },
{
type: "text",
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "src/" } },
},
],
}) })
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }]) })
test("preserves attachment order after the prompt", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-mixed-files"),
type: "user",
text: "Review these attachments",
files: [
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
}),
FileAttachment.make({
data: Base64.make(Buffer.from("export const value = 1").toString("base64")),
mime: "text/plain",
source: { type: "uri", uri: "file:///project/main.ts" },
name: "main.ts",
}),
],
time: { created },
}),
],
model,
)
expect(messages).toHaveLength(1)
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
"Review these attachments",
"\n\nAttached directory: src/\n\nindex.ts",
"\n\nAttached file: main.ts\n\nexport const value = 1",
])
})
test("omits empty prompt text before an attachment", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-attachment-only"),
type: "user",
text: "",
files: [
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
}),
],
time: { created },
}),
],
model,
)
expect(messages).toHaveLength(1)
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
}) })
test("uses materialized image data as provider media and drops unsupported attachments", () => { test("uses materialized image data as provider media and drops unsupported attachments", () => {