Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions PAPERCUTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# PAPERCUTS

Small, non-blocking frictions encountered by agents while working. Review this file periodically and sand them down.

## 535508 · 2026-08-29T19:31:36.411Z — codex — gpt-5.6-sol

- **Directory:** `/Users/safzan/Development/projects/opencode2work/opencode`
- **About:** `shell`
- **Tags:** `shell-quoting`

A combined zsh inspection command failed before execution because a single-quoted rg pattern contained an embedded quote. Use separate fixed-string searches or simpler quoting for mixed TypeScript import patterns.

## 795533 · 2026-08-29T19:40:54.180Z — codex — gpt-5.6-sol

- **Directory:** `/Users/safzan/Development/projects/opencode2work/opencode`
- **About:** `bun-test`
- **Tags:** `tooling`

Running prompt submit and server utility tests in one Bun process leaked submit.test.ts's partial module mock into later files, causing unrelated imports to report a missing base64Decode export. Run mock-heavy files in isolated Bun processes or make the mock export-complete.

10 changes: 5 additions & 5 deletions packages/app/src/components/prompt-input/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,19 @@ describe("attachmentMime", () => {
expect(await attachmentMime(file)).toBe("application/pdf")
})

test("normalizes structured text types to text/plain", async () => {
test("keeps structured text types for the upload", async () => {
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
expect(await attachmentMime(file)).toBe("text/plain")
expect(await attachmentMime(file)).toBe("application/json")
})

test("accepts text files even with a misleading browser mime", async () => {
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
expect(await attachmentMime(file)).toBe("text/plain")
expect(await attachmentMime(file)).toBe("video/mp2t")
})

test("rejects binary files", async () => {
test("accepts arbitrary binary files", async () => {
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
expect(await attachmentMime(file)).toBeUndefined()
expect(await attachmentMime(file)).toBe("application/octet-stream")
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
attachments: [
{
uri: "opencode://attachment/att_1",
name: "a.png",
mime: "image/png",
previewUrl: "blob:preview-1",
},
],
text: "hello @src/foo.ts @planner",
messageID: "msg_1",
Expand Down Expand Up @@ -53,14 +58,18 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
attachments: [
{
uri: "opencode://attachment/att_1",
name: "a.png",
mime: "image/png",
previewUrl: "blob:preview-1",
},
{
type: "image",
id: "img_2",
filename: "b.pdf",
uri: "opencode://attachment/att_2",
name: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
previewUrl: "blob:preview-2",
},
],
text: "check these",
Expand All @@ -69,24 +78,24 @@ describe("buildRequestParts", () => {
sessionDirectory: "/repo",
})

const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
const files = result.requestParts.filter(
(part) => part.type === "file" && part.url.startsWith("opencode://attachment/"),
)

expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})

test("preserves an external attachment source path for the model", () => {
test("uses one managed URI representation while preserving the local preview", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
attachments: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
uri: "opencode://attachment/att_external",
name: "opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
previewUrl: "blob:external",
},
],
text: "inspect this",
Expand All @@ -95,9 +104,11 @@ describe("buildRequestParts", () => {
sessionDirectory: "C:\\Repos\\sst\\opencode",
})

expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
expect(result.requestParts.find((part) => part.type === "file")).toMatchObject({
url: "opencode://attachment/att_external",
filename: "opencode.global.dat",
})
expect(result.optimisticParts.find((part) => part.type === "file")).toMatchObject({ url: "blob:external" })
})

test("preserves reference aliases as directory file parts", () => {
Expand All @@ -114,7 +125,7 @@ describe("buildRequestParts", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
Expand Down Expand Up @@ -144,7 +155,7 @@ describe("buildRequestParts", () => {
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
attachments: [],
text: "@src/foo.ts",
messageID: "msg_2",
sessionID: "ses_2",
Expand All @@ -171,7 +182,7 @@ describe("buildRequestParts", () => {
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
attachments: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
Expand All @@ -190,7 +201,7 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\foo.ts",
messageID: "msg_win_1",
sessionID: "ses_win_1",
Expand All @@ -216,7 +227,7 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [],
images: [],
attachments: [],
text: "@file#name.txt",
messageID: "msg_win_2",
sessionID: "ses_win_2",
Expand All @@ -241,7 +252,7 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [],
images: [],
attachments: [],
text: "@src/app.ts",
messageID: "msg_linux_1",
sessionID: "ses_linux_1",
Expand All @@ -264,7 +275,7 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [],
images: [],
attachments: [],
text: "@README.md",
messageID: "msg_mac_1",
sessionID: "ses_mac_1",
Expand All @@ -290,7 +301,7 @@ describe("buildRequestParts", () => {
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
attachments: [],
text: "test",
messageID: "msg_win_ctx",
sessionID: "ses_win_ctx",
Expand All @@ -317,7 +328,7 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [],
images: [],
attachments: [],
text: "@D:\\other\\project\\file.ts",
messageID: "msg_abs",
sessionID: "ses_abs",
Expand Down Expand Up @@ -348,7 +359,7 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\App.tsx",
messageID: "msg_sel",
sessionID: "ses_sel",
Expand Down Expand Up @@ -377,7 +388,7 @@ describe("buildRequestParts", () => {
const result = buildRequestParts({
prompt,
context: [],
images: [],
attachments: [],
text: "@..\\..\\shared\\util.ts",
messageID: "msg_dots",
sessionID: "ses_dots",
Expand Down
26 changes: 19 additions & 7 deletions packages/app/src/components/prompt-input/build-request-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import type { AgentPart, FileAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"

Expand All @@ -22,7 +22,12 @@ type ContextFile = {
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
attachments: Array<{
uri: string
name: string
mime: string
previewUrl: string
}>
text: string
messageID: string
sessionID: string
Expand Down Expand Up @@ -194,20 +199,27 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
]
})

const images = input.images.map((attachment) => {
const attachments = input.attachments.map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.sourcePath ?? attachment.filename,
url: attachment.uri,
filename: attachment.name,
} satisfies PromptRequestPart
})

requestParts.push(...files, ...context, ...agents, ...images)
requestParts.push(...files, ...context, ...agents, ...attachments)
// TODO(review): Give shared draft blob URLs explicit ownership before revoking them after optimistic replacement.
const previews = new Map(input.attachments.map((attachment) => [attachment.uri, attachment.previewUrl]))

return {
requestParts,
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
optimisticParts: requestParts.map((part) => {
const optimistic = toOptimisticPart(part, input.sessionID, input.messageID)
if (optimistic.type !== "file") return optimistic
const preview = previews.get(optimistic.url)
return preview ? { ...optimistic, url: preview } : optimistic
}),
}
}
46 changes: 4 additions & 42 deletions packages/app/src/components/prompt-input/files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
import { ACCEPTED_FILE_TYPES } from "@/constants/file-picker"

export { ACCEPTED_FILE_TYPES }

Expand Down Expand Up @@ -34,26 +34,13 @@ export function pickAttachmentFiles(input: {
.catch(input.onError)
}

const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])

const SAMPLE = 4096

function kind(type: string) {
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
}
Expand All @@ -64,35 +51,10 @@ function ext(name: string) {
return name.slice(idx + 1).toLowerCase()
}

function textMime(type: string) {
if (!type) return false
if (type.startsWith("text/")) return true
if (TEXT_MIMES.has(type)) return true
if (type.endsWith("+json")) return true
return type.endsWith("+xml")
}

function textBytes(bytes: Uint8Array) {
if (bytes.length === 0) return true
let count = 0
for (const byte of bytes) {
if (byte === 0) return false
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
}
return count / bytes.length <= 0.3
}

export async function attachmentMime(file: File) {
export function attachmentMime(file: File) {
const type = kind(file.type)
if (IMAGE_MIMES.has(type)) return type
if (type === "application/pdf") return type

const suffix = ext(file.name)
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback

if (textMime(type)) return "text/plain"
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
if (!textBytes(bytes)) return
return "text/plain"
if (type && type !== "application/octet-stream") return type
return fallback ?? "application/octet-stream"
}
Loading
Loading