fix(core): preserve prompt attachment order (#35921)
This commit is contained in:
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user