send images to opencode, which never needed the fork

B4 properly. The composer gate was the honest stopgap; this is the fix. opencode run takes
attachments with --file, so images work on the subprocess path we already use — the parity
doc had them down as phase 4, behind the serve migration, and they were not.

The bug was one omission: handleOpenCodeChat`s msg type had no images field, so the browser
sent them, the bubble rendered them, and they stopped at that signature. Nothing reported a
loss anywhere.

Attachments are paths, not inline data, so the sidecar spills each image to a temp file for
the length of the turn and removes it in settle — the same place every other per-turn
resource is released, so a killed or superseded turn cleans up too.

The load-bearing detail is `--` before the prompt: --file is an array option, so without the
separator the prompt is eaten as another filename and the turn dies with "File not found:"
followed by the entire message. Confirmed against the binary, and pinned by a test that
records argv from a stub.

list-models now reports each model own capability instead of a hardcoded false — opencode
publishes capabilities.input.image per model and nothing had ever read it. Defaults to false,
so a model that does not declare it keeps the affordance hidden.

Verified end to end: a red png sent over the chat socket to opencode/claude-sonnet-4-6 came
back "Red".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 16:49:39 +01:00
co-authored by Claude Opus 5
parent a41abb4b0f
commit 73b8111216
6 changed files with 149 additions and 18 deletions
+13 -8
View File
@@ -39,8 +39,12 @@ export function invalidateModelCache(): void {
openCodeCache = null;
}
type OpenCodeModel = {
capabilities?: { input?: { image?: boolean }; reasoning?: boolean };
};
type ProvidersResponse = {
providers?: Array<{ id?: string; models?: Record<string, unknown> }>;
providers?: Array<{ id?: string; models?: Record<string, OpenCodeModel> }>;
};
// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess).
@@ -58,19 +62,20 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
for (const provider of data.providers ?? []) {
const providerId = provider.id ?? '';
for (const modelId of Object.keys(provider.models ?? {})) {
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
models.push({
id: `${providerId}/${modelId}`,
name: modelId,
provider: providerId,
contextWindow: 200000,
maxTokens: 8192,
reasoning: false,
// False because nothing carries them: `handleOpenCodeChat`'s message type has no `images`
// field, so an attached image is rendered in the bubble, never sent, and silently dropped.
// The composer gates on this flag, so advertising `true` offered a capability that did not
// exist. Flip it back when images are plumbed through OpenCodeRunParams (parity doc, Phase 4).
images: false,
reasoning: model?.capabilities?.reasoning ?? false,
// Was hardcoded `false`, correctly, while nothing carried images — the composer gates on this
// flag, so advertising `true` offered a capability that did not exist. Images are now plumbed
// through `OpenCodeRunParams` to `opencode run --file`, so the honest answer is the model's
// own: OpenCode publishes it per model and we had never read it. Defaults to false, so a model
// that does not declare the capability keeps the affordance hidden rather than offering it.
images: model?.capabilities?.input?.image ?? false,
});
}
}
+5
View File
@@ -396,6 +396,10 @@ async function handleOpenCodeChat(
cwd?: string;
cwdRoot?: string;
resumeSessionId?: string;
// The whole of B4 lived in this omission. The browser sent images, the bubble rendered them, and
// they stopped at this signature — so they were never passed on and never reached the model, with
// nothing anywhere reporting a loss.
images?: PromptImage[];
},
effectivePrompt: string,
): Promise<void> {
@@ -457,6 +461,7 @@ async function handleOpenCodeChat(
cwd,
model,
resumeSessionId: msg.resumeSessionId,
images: msg.images,
onMessage,
});