Skip to content

Commit 0a9c595

Browse files
committed
fix(acp): preserve file attachment metadata during session replay
- Filter synthetic text parts during ACP replay - Replay file parts as ACP content blocks (images, binary resources, text resources) - Add audio content support in prompts - Convert resources to file parts with data URLs preserving metadata - Whitelist binary content for LLM (image/audio/video/pdf as file parts, everything else as text)
1 parent fb9c79d commit 0a9c595

File tree

2 files changed

+73
-40
lines changed

2 files changed

+73
-40
lines changed

packages/opencode/src/acp/agent.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ export namespace ACP {
612612
}
613613
}
614614
if (part.type === "text") {
615-
if (part.text) {
615+
if (part.text && !part.synthetic) {
616616
await this.connection
617617
.sessionUpdate({
618618
sessionId,
@@ -677,15 +677,33 @@ export namespace ACP {
677677
continue
678678
}
679679

680-
// Skip binary non-image files - ACP resource blocks only support text content
681-
const isTextBased =
682-
mime.startsWith("text/") ||
683-
mime === "application/json" ||
684-
mime === "application/xml" ||
685-
mime === "application/javascript" ||
686-
mime === "application/typescript" ||
687-
mime === "application/x-directory"
688-
if (!isTextBased) continue
680+
const isBinaryContent =
681+
mime.startsWith("audio/") ||
682+
mime.startsWith("video/") ||
683+
mime === "application/pdf" ||
684+
mime === "application/octet-stream"
685+
686+
if (isBinaryContent) {
687+
await this.connection
688+
.sessionUpdate({
689+
sessionId,
690+
update: {
691+
sessionUpdate: "user_message_chunk",
692+
content: {
693+
type: "resource",
694+
resource: {
695+
uri: `file://${filename}`,
696+
mimeType: mime,
697+
blob: base64Data,
698+
},
699+
},
700+
},
701+
})
702+
.catch((err) => {
703+
log.error("failed to send binary resource to ACP", { error: err })
704+
})
705+
continue
706+
}
689707

690708
const text = Buffer.from(base64Data, "base64").toString("utf-8")
691709
await this.connection
@@ -908,6 +926,18 @@ export namespace ACP {
908926
break
909927
}
910928

929+
case "audio":
930+
if (part.data) {
931+
const ext = part.mimeType.split("/")[1] || "audio"
932+
parts.push({
933+
type: "file",
934+
url: `data:${part.mimeType};base64,${part.data}`,
935+
filename: `audio.${ext}`,
936+
mime: part.mimeType,
937+
})
938+
}
939+
break
940+
911941
case "resource_link":
912942
const parsed = parseUri(part.uri)
913943
parts.push(parsed)
@@ -919,7 +949,7 @@ export namespace ACP {
919949
const filename = resource.uri?.replace(/^file:\/\//, "").split("/").pop() || "file"
920950
const mime = resource.mimeType || "text/plain"
921951

922-
if ("text" in resource) {
952+
if ("text" in resource && resource.text) {
923953
const base64 = Buffer.from(resource.text, "utf-8").toString("base64")
924954
parts.push({
925955
type: "file",
@@ -929,14 +959,21 @@ export namespace ACP {
929959
})
930960
break
931961
}
932-
if ("blob" in resource) {
962+
if ("blob" in resource && resource.blob) {
933963
parts.push({
934964
type: "file",
935965
url: `data:${mime};base64,${resource.blob}`,
936966
filename,
937967
mime,
938968
})
969+
break
939970
}
971+
parts.push({
972+
type: "file",
973+
url: resource.uri || `file://${filename}`,
974+
filename,
975+
mime,
976+
})
940977
break
941978
}
942979

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

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -439,39 +439,35 @@ export namespace MessageV2 {
439439
text: part.text,
440440
})
441441
if (part.type === "file") {
442-
// Skip directory markers
443442
if (part.mime === "application/x-directory") continue
444443

445-
const isTextBased =
446-
part.mime.startsWith("text/") ||
447-
part.mime === "application/json" ||
448-
part.mime === "application/xml" ||
449-
part.mime === "application/javascript" ||
450-
part.mime === "application/typescript"
451-
452-
if (isTextBased) {
453-
// Decode text-based files and send as text with filename header
454-
const url = part.url
455-
if (url.startsWith("data:")) {
456-
const match = url.match(/^data:[^;]+;base64,(.*)$/)
457-
if (match) {
458-
const text = Buffer.from(match[1], "base64").toString("utf-8")
459-
userMessage.parts.push({
460-
type: "text",
461-
text: `[File: ${part.filename || "file"}]\n${text}`,
462-
})
463-
}
464-
}
444+
const isBinaryContent =
445+
part.mime.startsWith("image/") ||
446+
part.mime.startsWith("audio/") ||
447+
part.mime.startsWith("video/") ||
448+
part.mime === "application/pdf"
449+
450+
if (isBinaryContent) {
451+
userMessage.parts.push({
452+
type: "file",
453+
url: part.url,
454+
mediaType: part.mime,
455+
filename: part.filename,
456+
})
465457
continue
466458
}
467459

468-
// Send binary files (images, etc.) as file parts
469-
userMessage.parts.push({
470-
type: "file",
471-
url: part.url,
472-
mediaType: part.mime,
473-
filename: part.filename,
474-
})
460+
const url = part.url
461+
if (url.startsWith("data:")) {
462+
const match = url.match(/^data:[^;]+;base64,(.*)$/)
463+
if (match) {
464+
const text = Buffer.from(match[1], "base64").toString("utf-8")
465+
userMessage.parts.push({
466+
type: "text",
467+
text: `[File: ${part.filename || "file"}]\n${text}`,
468+
})
469+
}
470+
}
475471
}
476472

477473
if (part.type === "compaction") {

0 commit comments

Comments
 (0)