Skip to content

Commit 8fd1b92

Browse files
authored
fix: ensure that tool attachments arent sent as user messages (anomalyco#8944)
1 parent 22e3240 commit 8fd1b92

File tree

3 files changed

+89
-43
lines changed

3 files changed

+89
-43
lines changed

packages/opencode/src/session/message-v2.ts

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import z from "zod"
33
import { NamedError } from "@opencode-ai/util/error"
4-
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
4+
import {
5+
APICallError,
6+
convertToModelMessages,
7+
LoadAPIKeyError,
8+
type ModelMessage,
9+
type ToolSet,
10+
type UIMessage,
11+
} from "ai"
512
import { Identifier } from "../id/id"
613
import { LSP } from "../lsp"
714
import { Snapshot } from "@/snapshot"
@@ -432,7 +439,7 @@ export namespace MessageV2 {
432439
})
433440
export type WithParts = z.infer<typeof WithParts>
434441

435-
export function toModelMessage(input: WithParts[]): ModelMessage[] {
442+
export function toModelMessage(input: WithParts[], options?: { tools?: ToolSet }): ModelMessage[] {
436443
const result: UIMessage[] = []
437444

438445
for (const msg of input) {
@@ -503,30 +510,14 @@ export namespace MessageV2 {
503510
})
504511
if (part.type === "tool") {
505512
if (part.state.status === "completed") {
506-
if (part.state.attachments?.length) {
507-
result.push({
508-
id: Identifier.ascending("message"),
509-
role: "user",
510-
parts: [
511-
{
512-
type: "text",
513-
text: `Tool ${part.tool} returned an attachment:`,
514-
},
515-
...part.state.attachments.map((attachment) => ({
516-
type: "file" as const,
517-
url: attachment.url,
518-
mediaType: attachment.mime,
519-
filename: attachment.filename,
520-
})),
521-
],
522-
})
523-
}
524513
assistantMessage.parts.push({
525514
type: ("tool-" + part.tool) as `tool-${string}`,
526515
state: "output-available",
527516
toolCallId: part.callID,
528517
input: part.state.input,
529-
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
518+
output: part.state.time.compacted
519+
? { output: "[Old tool result content cleared]" }
520+
: { output: part.state.output, attachments: part.state.attachments },
530521
callProviderMetadata: part.metadata,
531522
})
532523
}
@@ -565,7 +556,10 @@ export namespace MessageV2 {
565556
}
566557
}
567558

568-
return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
559+
return convertToModelMessages(
560+
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
561+
{ tools: options?.tools },
562+
)
569563
}
570564

571565
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {

packages/opencode/src/session/prompt.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,7 @@ export namespace SessionPrompt {
597597
sessionID,
598598
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
599599
messages: [
600-
...MessageV2.toModelMessage(sessionMessages),
600+
...MessageV2.toModelMessage(sessionMessages, { tools }),
601601
...(isLastStep
602602
? [
603603
{
@@ -718,8 +718,22 @@ export namespace SessionPrompt {
718718
},
719719
toModelOutput(result) {
720720
return {
721-
type: "text",
722-
value: result.output,
721+
type: "content",
722+
value: [
723+
{
724+
type: "text",
725+
text: result.output,
726+
},
727+
...(result.attachments?.map((attachment: MessageV2.FilePart) => {
728+
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
729+
730+
return {
731+
type: "media",
732+
data: base64,
733+
mediaType: attachment.mime,
734+
}
735+
}) ?? []),
736+
],
723737
}
724738
},
725739
})
@@ -808,8 +822,22 @@ export namespace SessionPrompt {
808822
}
809823
item.toModelOutput = (result) => {
810824
return {
811-
type: "text",
812-
value: result.output,
825+
type: "content",
826+
value: [
827+
{
828+
type: "text",
829+
text: result.output,
830+
},
831+
...(result.attachments?.map((attachment: MessageV2.FilePart) => {
832+
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
833+
834+
return {
835+
type: "media",
836+
data: base64,
837+
mediaType: attachment.mime,
838+
}
839+
}) ?? []),
840+
],
813841
}
814842
}
815843
tools[key] = item

packages/opencode/test/session/message-v2.test.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
import { describe, expect, test } from "bun:test"
22
import { MessageV2 } from "../../src/session/message-v2"
3+
import type { ToolSet } from "ai"
34

45
const sessionID = "session"
56

7+
// Mock tool that transforms output to content format with media support
8+
function createMockTools(): ToolSet {
9+
return {
10+
bash: {
11+
description: "mock bash tool",
12+
inputSchema: { type: "object", properties: {} } as any,
13+
toModelOutput(result: { output: string; attachments?: MessageV2.FilePart[] }) {
14+
return {
15+
type: "content" as const,
16+
value: [
17+
{ type: "text" as const, text: result.output },
18+
...(result.attachments?.map((attachment) => {
19+
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
20+
return {
21+
type: "media" as const,
22+
data: base64,
23+
mediaType: attachment.mime,
24+
}
25+
}) ?? []),
26+
],
27+
}
28+
},
29+
},
30+
} as ToolSet
31+
}
32+
633
function userInfo(id: string): MessageV2.User {
734
return {
835
id,
@@ -259,23 +286,11 @@ describe("session.message-v2.toModelMessage", () => {
259286
},
260287
]
261288

262-
expect(MessageV2.toModelMessage(input)).toStrictEqual([
289+
expect(MessageV2.toModelMessage(input, { tools: createMockTools() })).toStrictEqual([
263290
{
264291
role: "user",
265292
content: [{ type: "text", text: "run tool" }],
266293
},
267-
{
268-
role: "user",
269-
content: [
270-
{ type: "text", text: "Tool bash returned an attachment:" },
271-
{
272-
type: "file",
273-
mediaType: "image/png",
274-
filename: "attachment.png",
275-
data: "https://example.com/attachment.png",
276-
},
277-
],
278-
},
279294
{
280295
role: "assistant",
281296
content: [
@@ -297,7 +312,13 @@ describe("session.message-v2.toModelMessage", () => {
297312
type: "tool-result",
298313
toolCallId: "call-1",
299314
toolName: "bash",
300-
output: { type: "text", value: "ok" },
315+
output: {
316+
type: "content",
317+
value: [
318+
{ type: "text", text: "ok" },
319+
{ type: "media", data: "https://example.com/attachment.png", mediaType: "image/png" },
320+
],
321+
},
301322
providerOptions: { openai: { tool: "meta" } },
302323
},
303324
],
@@ -341,7 +362,7 @@ describe("session.message-v2.toModelMessage", () => {
341362
},
342363
]
343364

344-
expect(MessageV2.toModelMessage(input)).toStrictEqual([
365+
expect(MessageV2.toModelMessage(input, { tools: createMockTools() })).toStrictEqual([
345366
{
346367
role: "user",
347368
content: [{ type: "text", text: "run tool" }],
@@ -365,7 +386,10 @@ describe("session.message-v2.toModelMessage", () => {
365386
type: "tool-result",
366387
toolCallId: "call-1",
367388
toolName: "bash",
368-
output: { type: "text", value: "[Old tool result content cleared]" },
389+
output: {
390+
type: "content",
391+
value: [{ type: "text", text: "[Old tool result content cleared]" }],
392+
},
369393
},
370394
],
371395
},

0 commit comments

Comments
 (0)