fix: ensure that tool attachments arent sent as user messages (#8944)
This commit is contained in:
@@ -1,7 +1,14 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
import {
|
||||||
|
APICallError,
|
||||||
|
convertToModelMessages,
|
||||||
|
LoadAPIKeyError,
|
||||||
|
type ModelMessage,
|
||||||
|
type ToolSet,
|
||||||
|
type UIMessage,
|
||||||
|
} from "ai"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
@@ -432,7 +439,7 @@ export namespace MessageV2 {
|
|||||||
})
|
})
|
||||||
export type WithParts = z.infer<typeof WithParts>
|
export type WithParts = z.infer<typeof WithParts>
|
||||||
|
|
||||||
export function toModelMessage(input: WithParts[]): ModelMessage[] {
|
export function toModelMessage(input: WithParts[], options?: { tools?: ToolSet }): ModelMessage[] {
|
||||||
const result: UIMessage[] = []
|
const result: UIMessage[] = []
|
||||||
|
|
||||||
for (const msg of input) {
|
for (const msg of input) {
|
||||||
@@ -503,30 +510,14 @@ export namespace MessageV2 {
|
|||||||
})
|
})
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
if (part.state.status === "completed") {
|
if (part.state.status === "completed") {
|
||||||
if (part.state.attachments?.length) {
|
|
||||||
result.push({
|
|
||||||
id: Identifier.ascending("message"),
|
|
||||||
role: "user",
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Tool ${part.tool} returned an attachment:`,
|
|
||||||
},
|
|
||||||
...part.state.attachments.map((attachment) => ({
|
|
||||||
type: "file" as const,
|
|
||||||
url: attachment.url,
|
|
||||||
mediaType: attachment.mime,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
assistantMessage.parts.push({
|
assistantMessage.parts.push({
|
||||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||||
state: "output-available",
|
state: "output-available",
|
||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
input: part.state.input,
|
input: part.state.input,
|
||||||
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
|
output: part.state.time.compacted
|
||||||
|
? { output: "[Old tool result content cleared]" }
|
||||||
|
: { output: part.state.output, attachments: part.state.attachments },
|
||||||
callProviderMetadata: part.metadata,
|
callProviderMetadata: part.metadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -565,7 +556,10 @@ export namespace MessageV2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
|
return convertToModelMessages(
|
||||||
|
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
|
||||||
|
{ tools: options?.tools },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
|
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ export namespace SessionPrompt {
|
|||||||
sessionID,
|
sessionID,
|
||||||
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
|
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
|
||||||
messages: [
|
messages: [
|
||||||
...MessageV2.toModelMessage(sessionMessages),
|
...MessageV2.toModelMessage(sessionMessages, { tools }),
|
||||||
...(isLastStep
|
...(isLastStep
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -718,8 +718,22 @@ export namespace SessionPrompt {
|
|||||||
},
|
},
|
||||||
toModelOutput(result) {
|
toModelOutput(result) {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "content",
|
||||||
value: result.output,
|
value: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: result.output,
|
||||||
|
},
|
||||||
|
...(result.attachments?.map((attachment: MessageV2.FilePart) => {
|
||||||
|
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "media",
|
||||||
|
data: base64,
|
||||||
|
mediaType: attachment.mime,
|
||||||
|
}
|
||||||
|
}) ?? []),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -808,8 +822,22 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
item.toModelOutput = (result) => {
|
item.toModelOutput = (result) => {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "content",
|
||||||
value: result.output,
|
value: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: result.output,
|
||||||
|
},
|
||||||
|
...(result.attachments?.map((attachment: MessageV2.FilePart) => {
|
||||||
|
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "media",
|
||||||
|
data: base64,
|
||||||
|
mediaType: attachment.mime,
|
||||||
|
}
|
||||||
|
}) ?? []),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tools[key] = item
|
tools[key] = item
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import type { ToolSet } from "ai"
|
||||||
|
|
||||||
const sessionID = "session"
|
const sessionID = "session"
|
||||||
|
|
||||||
|
// Mock tool that transforms output to content format with media support
|
||||||
|
function createMockTools(): ToolSet {
|
||||||
|
return {
|
||||||
|
bash: {
|
||||||
|
description: "mock bash tool",
|
||||||
|
inputSchema: { type: "object", properties: {} } as any,
|
||||||
|
toModelOutput(result: { output: string; attachments?: MessageV2.FilePart[] }) {
|
||||||
|
return {
|
||||||
|
type: "content" as const,
|
||||||
|
value: [
|
||||||
|
{ type: "text" as const, text: result.output },
|
||||||
|
...(result.attachments?.map((attachment) => {
|
||||||
|
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
|
||||||
|
return {
|
||||||
|
type: "media" as const,
|
||||||
|
data: base64,
|
||||||
|
mediaType: attachment.mime,
|
||||||
|
}
|
||||||
|
}) ?? []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ToolSet
|
||||||
|
}
|
||||||
|
|
||||||
function userInfo(id: string): MessageV2.User {
|
function userInfo(id: string): MessageV2.User {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -259,23 +286,11 @@ describe("session.message-v2.toModelMessage", () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(MessageV2.toModelMessage(input)).toStrictEqual([
|
expect(MessageV2.toModelMessage(input, { tools: createMockTools() })).toStrictEqual([
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "run tool" }],
|
content: [{ type: "text", text: "run tool" }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "Tool bash returned an attachment:" },
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mediaType: "image/png",
|
|
||||||
filename: "attachment.png",
|
|
||||||
data: "https://example.com/attachment.png",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [
|
content: [
|
||||||
@@ -297,7 +312,13 @@ describe("session.message-v2.toModelMessage", () => {
|
|||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
toolName: "bash",
|
toolName: "bash",
|
||||||
output: { type: "text", value: "ok" },
|
output: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "ok" },
|
||||||
|
{ type: "media", data: "https://example.com/attachment.png", mediaType: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
providerOptions: { openai: { tool: "meta" } },
|
providerOptions: { openai: { tool: "meta" } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -341,7 +362,7 @@ describe("session.message-v2.toModelMessage", () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(MessageV2.toModelMessage(input)).toStrictEqual([
|
expect(MessageV2.toModelMessage(input, { tools: createMockTools() })).toStrictEqual([
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "run tool" }],
|
content: [{ type: "text", text: "run tool" }],
|
||||||
@@ -365,7 +386,10 @@ describe("session.message-v2.toModelMessage", () => {
|
|||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
toolName: "bash",
|
toolName: "bash",
|
||||||
output: { type: "text", value: "[Old tool result content cleared]" },
|
output: {
|
||||||
|
type: "content",
|
||||||
|
value: [{ type: "text", text: "[Old tool result content cleared]" }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user