diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts index 6160f79a..a077b372 100644 --- a/src/servers/sidecar/opencode/serve-runner.ts +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -188,7 +188,10 @@ export async function runOpenCodeTurnOnServe( try { await serveJson(config, `/api/session/${live.openCodeSessionId}/prompt`, { method: 'POST', - body: JSON.stringify({ prompt: { text: params.prompt }, delivery: 'steer' }), + body: JSON.stringify({ + prompt: { text: params.prompt, files: promptFiles(params.images) }, + delivery: 'steer', + }), cwd, }); } catch (err) { @@ -255,7 +258,10 @@ export async function runOpenCodeTurnOnServe( // `delivery` is stated explicitly because it DEFAULTS to `"steer"`, which injects into a running // turn. For an ordinary send that is the wrong default — two quick messages would merge into one // turn instead of running in order. `steer` is Phase C's job, wired to the button that means it. - body: JSON.stringify({ prompt: { text: params.prompt }, delivery: 'queue' }), + body: JSON.stringify({ + prompt: { text: params.prompt, files: promptFiles(params.images) }, + delivery: 'queue', + }), cwd, }); } catch (err) { @@ -273,6 +279,28 @@ function retire(turn: ServeTurn, event: ChatEvent | null): void { const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err)); +/** + * Images as `prompt.files`, which this surface takes by URI. + * + * **`data:` URIs, not `file://`.** Measured, because the choice is not obvious and the wrong one fails + * at the provider rather than at the API: a `file://` attachment is accepted with a 200 and then dies + * inside the turn with `Anthropic Messages media must contain valid base64`. A `data:` URI round-trips + * and the model describes the image. + * + * This is strictly better than the subprocess path, which has to spill each image to a temp file for + * `--file` and delete it afterwards. Here the bytes go in the request and there is nothing to clean up. + * + * Silently dropping these is exactly defect B4 — the user sees their image in their own bubble and the + * model never receives it — so this exists before the serve path is switched on for anyone, not after. + */ +function promptFiles(images: OpenCodeRunParams['images']): { uri: string; name: string }[] | undefined { + if (!images?.length) return undefined; + return images.map((image, index) => ({ + uri: `data:${image.mediaType || 'image/png'};base64,${image.data}`, + name: `attachment-${index + 1}`, + })); +} + /** The serve analog of `listRunningOpenCodeTurns`. Same shape, so the Live panel needs no changes. */ export function listRunningServeTurns(): { sessionKey: string }[] { return [...bySessionKey.keys()].map((sessionKey) => ({ sessionKey }));