fix(core): keep resolved attachments hidden

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
Kit Langton 2026-06-30 14:44:46 +00:00 committed by 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
commit 573ce945a6
3 changed files with 70 additions and 41 deletions

View file

@ -676,16 +676,22 @@ function resolveInlineFileAttachment(file: PromptInput.FileAttachment) {
if (mime) {
if (textMime(mime)) {
return {
file: { ...file, mime },
text: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
file: {
...file,
mime,
description: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
},
}
}
return { file: { ...file, mime } }
}
if (!URL.canParse(file.uri)) {
return {
file: { ...file, mime: "application/octet-stream" },
text: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
file: {
...file,
mime: "application/octet-stream",
description: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
},
}
}
const url = new URL(file.uri)
@ -696,7 +702,7 @@ function resolveInlineFileAttachment(file: PromptInput.FileAttachment) {
function resolveInlinePrompt(input: PromptInput.Prompt) {
const files = (input.files ?? []).map(resolveInlineFileAttachment)
return Prompt.make({
text: [input.text, ...files.flatMap((file) => (file.text ? [file.text] : []))].filter(Boolean).join("\n\n"),
text: input.text,
agents: input.agents,
files: files.map((file) => file.file),
})
@ -707,8 +713,11 @@ const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(functio
if (mime) {
if (textMime(mime)) {
return {
file: { ...file, mime },
text: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
file: {
...file,
mime,
description: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
},
}
}
return { file: { ...file, mime } }
@ -716,8 +725,11 @@ const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(functio
if (!URL.canParse(file.uri)) {
return {
file: { ...file, mime: "application/octet-stream" },
text: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
file: {
...file,
mime: "application/octet-stream",
description: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
},
}
}
@ -733,12 +745,15 @@ const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(functio
const relative = path.relative(location.directory, filepath)
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return {
file: { ...file, mime: FSUtil.mimeType(filepath) },
text: attachmentError({
name: file.name ?? filepath,
uri: file.uri,
message: "file is outside the session location",
}),
file: {
...file,
mime: FSUtil.mimeType(filepath),
description: attachmentError({
name: file.name ?? filepath,
uri: file.uri,
message: "file is outside the session location",
}),
},
}
}
@ -751,21 +766,27 @@ const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(functio
.map((entry) => `${entry.path}${entry.type === "directory" ? "/" : ""}`)
.join("\n")
return {
file: { ...file, mime: "application/x-directory" },
text: formatAttachmentText({
name: file.name ?? filepath,
uri: file.uri,
text: visible || "[Directory is empty]",
truncated: entries.length > MAX_DIRECTORY_ENTRIES,
}),
file: {
...file,
mime: "application/x-directory",
description: formatAttachmentText({
name: file.name ?? filepath,
uri: file.uri,
text: visible || "[Directory is empty]",
truncated: entries.length > MAX_DIRECTORY_ENTRIES,
}),
},
}
}
const read = yield* filesystem.read({ path: target }).pipe(Effect.exit)
if (Exit.isFailure(read)) {
return {
file: { ...file, mime: FSUtil.mimeType(filepath) },
text: attachmentError({ name: file.name ?? filepath, uri: file.uri, message: "file not found" }),
file: {
...file,
mime: FSUtil.mimeType(filepath),
description: attachmentError({ name: file.name ?? filepath, uri: file.uri, message: "file not found" }),
},
}
}
const content = read.value
@ -774,8 +795,11 @@ const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(functio
const selected = selectedLines(Buffer.from(content.content).toString("utf8"), url)
const truncated = truncateAttachmentText(selected.text, selected.truncated)
return {
file: { ...file, mime: "text/plain" },
text: formatAttachmentText({ name: file.name ?? filepath, uri: file.uri, ...truncated }),
file: {
...file,
mime: "text/plain",
description: formatAttachmentText({ name: file.name ?? filepath, uri: file.uri, ...truncated }),
},
}
}
@ -790,19 +814,22 @@ const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(functio
}
return {
file: { ...file, mime: content.mime },
text: attachmentError({
name: file.name ?? filepath,
uri: file.uri,
message: `unsupported file type ${content.mime}`,
}),
file: {
...file,
mime: content.mime,
description: attachmentError({
name: file.name ?? filepath,
uri: file.uri,
message: `unsupported file type ${content.mime}`,
}),
},
}
})
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const files = yield* Effect.forEach(input.files ?? [], resolveFileAttachment, { concurrency: "unbounded" })
return Prompt.make({
text: [input.text, ...files.flatMap((file) => (file.text ? [file.text] : []))].filter(Boolean).join("\n\n"),
text: input.text,
agents: input.agents,
files: files.map((file) => file.file),
})

View file

@ -11,7 +11,9 @@ import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
const media = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain" || file.mime === "application/x-directory") return []
if (file.mime === "text/plain" || file.mime === "application/x-directory") {
return file.description ? [{ type: "text", text: file.description }] : []
}
if (!file.mime.startsWith("image/") && file.mime !== "application/pdf") return []
return [
{
@ -19,7 +21,6 @@ const media = (file: FileAttachment): ContentPart[] => {
mediaType: file.mime,
data: file.uri,
filename: file.name,
metadata: file.description === undefined ? undefined : { description: file.description },
},
]
}

View file

@ -248,9 +248,9 @@ describe("SessionV2.prompt", () => {
resume: false,
})
expect(message.prompt.text).toContain("explain this")
expect(message.prompt.text).toContain('<attachment name="src/example.ts#2-3">')
expect(message.prompt.text).toContain("two\nthree")
expect(message.prompt.text).toBe("explain this")
expect(message.prompt.files?.[0]?.description).toContain('<attachment name="src/example.ts#2-3">')
expect(message.prompt.files?.[0]?.description).toContain("two\nthree")
expect(message.prompt.files?.[0]?.mime).toBe("text/plain")
expect(message.prompt.files?.[0]?.source?.text).toBe("@src/example.ts#2-3")
}),
@ -274,9 +274,10 @@ describe("SessionV2.prompt", () => {
resume: false,
})
expect(message.prompt.text).toContain('<attachment name="src/">')
expect(message.prompt.text).toContain("index.ts")
expect(message.prompt.text).toContain("nested/")
expect(message.prompt.text).toBe("inspect directory")
expect(message.prompt.files?.[0]?.description).toContain('<attachment name="src/">')
expect(message.prompt.files?.[0]?.description).toContain("index.ts")
expect(message.prompt.files?.[0]?.description).toContain("nested/")
expect(message.prompt.files?.[0]?.mime).toBe("application/x-directory")
}),
)