From b79eca45fb315c6a1242e6ebfbb6f7402c6e0bc7 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 19:25:48 +0100 Subject: [PATCH] send images on the serve path too, before anyone switches to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runOpenCodeTurnOnServe ignored params.images entirely, which is defect B4 rebuilt on the new path: the image renders in your own bubble and the model never receives it, with nothing reporting a loss. Fixed before the path is switched on for anyone rather than after. data: URIs, not file://, and that is measured — the wrong choice is accepted with a 200 and then dies inside the turn with "Anthropic Messages media must contain valid base64". The data URI round-trips and the model describes the image. Strictly better than the subprocess path here: no temp file to spill and nothing to clean up, because the bytes travel in the request. Both prompt paths carry them — an ordinary send and a mid-turn injection. Verified end to end through the chat socket with the serve engine on: a red png came back "**Red**", with deltas streaming. Co-Authored-By: Claude Opus 5 --- src/servers/sidecar/opencode/serve-runner.ts | 32 ++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) 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 }));