20 KiB
Phase 4 - OpenCode file parts and model vision capability gate
Summary
Goal: support image attachments for OpenCode teammates only when the selected model/runtime can actually accept image parts, and show clear UI errors for text-only models.
Chosen approach: OpenCode file-part adapter + curated model vision capability catalog + unknown capability fail-safe + optional live smoke tooling.
🎯 8.3 🛡️ 8.0 🧠 7.2
Estimated change size: 320-560 LOC across two repos.
Repos:
/Users/belief/dev/projects/claude/claude_team/Users/belief/dev/projects/claude/agent_teams_orchestrator
Live proof
Validated manually:
opencode run --model openai/gpt-5.4-mini -f red-card-valid.png -> red
opencode run --model openrouter/moonshotai/kimi-k2.6 -f red-card-valid.png -> red
opencode run --model openrouter/z-ai/glm-4.5v -f red-card-valid.png -> red
opencode run --model openrouter/z-ai/glm-5.1 -f red-card-valid.png -> model says it cannot view images
OpenCode session export shows file part shape:
{
"type": "file",
"mime": "image/png",
"url": "data:image/png;base64,...",
"filename": "red-card-valid.png"
}
Therefore transport can carry images, but model capability must gate usage.
Current behavior
Current desktop delivery blocks OpenCode secondary attachments:
opencode_attachments_not_supported_for_secondary_runtime
This was safe. Phase 4 replaces the blanket block with a capability-aware path.
Capability catalog
Create a pure catalog in the attachment feature or shared OpenCode model utilities.
export type VisionCapability =
| { kind: 'supported'; source: 'curated' | 'provider-metadata' | 'live-probe' }
| { kind: 'unsupported'; source: 'curated' | 'model-response' | 'provider-metadata'; reason: string }
| { kind: 'unknown'; reason: string };
export function resolveOpenCodeVisionCapability(modelId: string): VisionCapability {
const normalized = normalizeOpenCodeModelId(modelId);
if (normalized === 'openrouter/z-ai/glm-5.1') {
return {
kind: 'unsupported',
source: 'curated',
reason: 'GLM 5.1 did not accept image input in live OpenCode/OpenRouter smoke test.',
};
}
if (
normalized === 'openai/gpt-5.4-mini' ||
normalized === 'openrouter/moonshotai/kimi-k2.6' ||
normalized === 'openrouter/z-ai/glm-4.5v'
) {
return { kind: 'supported', source: 'curated' };
}
if (/\b(vl|vision|image)\b/i.test(normalized)) {
return { kind: 'supported', source: 'provider-metadata' };
}
return {
kind: 'unknown',
reason: 'Image capability for this OpenCode model has not been verified.',
};
}
Policy decision:
supported: allow image delivery.unsupported: block with clear error.unknown: block by default for production sends, but allow future manual override only if explicitly designed.
Before release, do not allow unknown models to receive images silently.
OpenCode adapter
export class OpenCodeAttachmentAdapter implements AttachmentDeliveryAdapter {
readonly runtimeKind = 'opencode' as const;
canDeliver(ctx: AttachmentRuntimeContext, attachment: NormalizedAgentAttachment) {
if (attachment.kind !== 'image') {
return block('OpenCode currently supports image attachments only.');
}
const capability = resolveOpenCodeVisionCapability(ctx.modelId);
if (capability.kind === 'unsupported') {
return block(`${ctx.modelId} does not support image input. ${capability.reason}`);
}
if (capability.kind === 'unknown') {
return block(`Image input support for ${ctx.modelId} is unknown. Choose a verified vision model.`);
}
return allowIfMime(attachment, ['image/png', 'image/jpeg', 'image/webp']);
}
async prepare(ctx: AttachmentRuntimeContext, attachment: NormalizedAgentAttachment) {
const variant = selectOpenCodeFilePartVariant(attachment);
return {
runtimeKind: 'opencode',
attachmentId: attachment.id,
part: {
kind: 'opencode-file-part',
value: {
type: 'file',
mime: variant.mimeType,
url: toDataUrl(variant),
filename: attachment.originalName,
},
},
diagnostics: [`prepared OpenCode file part for ${ctx.modelId}`],
};
}
}
Orchestrator OpenCode bridge changes
Current bridge sends text-only parts. Extend to accept file parts.
export interface OpenCodePromptPart {
type: 'text' | 'file';
text?: string;
mime?: string;
url?: string;
filename?: string;
}
export interface SendOpenCodePromptInput {
sessionId: string;
parts: OpenCodePromptPart[];
}
When no attachments:
parts: [{ type: 'text', text: params.text }]
When attachments:
parts: [
{ type: 'text', text: params.text },
{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,...', filename: 'screenshot.png' },
]
Do not change proof gates. OpenCode delivery success still requires existing response proof:
- visible reply;
- safe plain-text materialization;
message_sendwith correct relay id;- work-sync report;
- task/progress evidence.
Attachment accepted by OpenCode is not response proof.
UI capability behavior
Examples:
GLM 5.1 does not support image input. Choose GLM 4.5V, Kimi K2.6, GPT-5.4-mini, Claude, or Codex.
Image input support for this OpenCode model is not verified. Choose a verified vision model before sending screenshots.
This image will be sent to Kimi K2.6 through OpenCode/OpenRouter.
Keep this next to attachment previews and also validate at send time.
Edge cases
OpenRouter provider missing
If OpenCode has no OpenRouter key/provider, model list may not include openrouter/*. This is provider auth/config issue, not attachment issue.
Expected error:
OpenRouter is not connected in OpenCode. Connect OpenRouter before using this model.
OpenRouter credits exhausted
Preserve exact provider error. Do not rewrite it as attachment failure.
Model accepts file part but says cannot see image
Treat this as model capability failure and update curated deny list only after repeated confirmed cases.
Do not mark delivery transport failed if OpenCode accepted the prompt and model responded.
Unknown model
Block image attachments by default. Text-only messages still work.
Multiple images
Allow only if total optimized budget is safe. Do not send partial images.
File part size
Use same optimized variants as Claude/Codex. OpenCode data URL still has base64 overhead, so backend serialized budget applies.
Retry
Retry must reuse the same original/variant metadata. Do not recompress differently on every retry unless original variant is missing.
Tests
Unit
openrouter/moonshotai/kimi-k2.6is supported;openrouter/z-ai/glm-4.5vis supported;openrouter/z-ai/glm-5.1is unsupported;- unknown OpenRouter model is blocked for image;
- OpenCode adapter emits
filepart with data URL; - no base64 appears in diagnostics;
- text-only OpenCode messages remain unchanged.
Bridge tests
- OpenCode bridge accepts text-only parts;
- OpenCode bridge accepts text plus file parts;
- unsupported part type rejects before API call;
- response observer/proof semantics unchanged.
Desktop service tests
- direct OpenCode secondary image send blocks unsupported model;
- direct OpenCode secondary image send prepares file part for supported model;
- provider/API error is preserved;
- attachment accepted does not set delivery success without response proof.
Live e2e
Only on explicit request:
OPENROUTER_API_KEY=... opencode run --pure --format json --dir /tmp --model openrouter/moonshotai/kimi-k2.6 "..." -f red-card-valid.png
OPENROUTER_API_KEY=... opencode run --pure --format json --dir /tmp --model openrouter/z-ai/glm-4.5v "..." -f red-card-valid.png
OPENROUTER_API_KEY=... opencode run --pure --format json --dir /tmp --model openrouter/z-ai/glm-5.1 "..." -f red-card-valid.png
Expected:
Kimi K2.6 -> red
GLM 4.5V -> red
GLM 5.1 -> unsupported/refusal
Safety checklist
- OpenCode proof gates unchanged.
- Unsupported models blocked before send.
- Provider auth errors preserved.
- No silent fallback to text base64.
- No model-specific prompt hacks.
- Capability catalog is pure and unit-tested.
Deep implementation details
Capability resolver should be pure
Do not call OpenCode or OpenRouter inside the send hot path.
export interface OpenCodeVisionCapabilityResolver {
resolve(modelId: string): VisionCapability;
}
Use static curated rules plus model id heuristics. Live probing belongs in Phase 5 tooling, not every send.
Capability result examples
resolve('openrouter/moonshotai/kimi-k2.6')
// { kind: 'supported', source: 'curated' }
resolve('openrouter/z-ai/glm-4.5v')
// { kind: 'supported', source: 'curated' }
resolve('openrouter/z-ai/glm-5.1')
// { kind: 'unsupported', source: 'curated', reason: 'Live smoke returned text-only refusal.' }
resolve('openrouter/qwen/qwen2.5-vl-72b-instruct')
// { kind: 'supported', source: 'provider-metadata' }
resolve('openrouter/minimax/minimax-m2.5')
// { kind: 'unknown', reason: 'No verified vision capability.' }
Model normalization
OpenCode may expose ids in different forms:
openrouter/moonshotai/kimi-k2.6
moonshotai/kimi-k2.6 via openrouter provider context
z-ai/glm-4.5v
Normalize consistently:
export function normalizeOpenCodeModelRef(input: string, providerId?: string): string {
const trimmed = input.trim().toLowerCase();
if (trimmed.startsWith('openrouter/')) return trimmed;
if (providerId === 'openrouter') return `openrouter/${trimmed}`;
return trimmed;
}
OpenCode bridge part schema
Use narrow supported schema. Do not allow arbitrary JSON parts from renderer/main.
export type OpenCodeBridgePromptPart =
| { type: 'text'; text: string }
| { type: 'file'; mime: 'image/png' | 'image/jpeg' | 'image/webp'; url: string; filename: string };
function assertOpenCodeBridgePromptPart(part: OpenCodeBridgePromptPart): void {
if (part.type === 'file') {
if (!part.url.startsWith(`data:${part.mime};base64,`)) {
throw new Error('Invalid OpenCode file part data URL.');
}
if (part.url.length > OPENCODE_FILE_PART_DATA_URL_MAX_CHARS) {
throw new Error('OpenCode file part is too large.');
}
}
}
Delivery result semantics
Do not change existing OpenCode delivery proof. Attachment support is input preparation only.
file part accepted != response delivered
response text exists != relay proof for peer messages
empty assistant turn != success
provider auth failure != retryable prompt repair
UI capability copy
Keep model-specific text concise:
Images are not supported by GLM 5.1 in OpenCode. Choose GLM 4.5V, Kimi K2.6, GPT-5.4-mini, Claude, or Codex.
For unknown:
Image support for this OpenCode model is not verified. Send text only or choose a verified vision model.
For missing provider auth:
OpenRouter is not connected for OpenCode. Connect OpenRouter before sending images to this model.
More edge cases
| Edge case | Expected behavior |
|---|---|
| OpenCode provider exists only through env key during tests | live smoke works, app-managed config still needs explicit provider setup |
| User pastes image to OpenCode text-only model | composer blocks send before creating ledger record |
| User sends text-only to GLM 5.1 | allowed, no image capability warning blocking send |
| Kimi accepts image but returns low-quality answer | delivery success if response proof exists; not attachment bug |
| OpenRouter key has quota limit | preserve exact provider error and show advisory/notification if runtime error path triggers |
OpenCode returns ProviderModelNotFoundError |
provider/model config error, not attachment serialization |
| File part accepted but no assistant response | existing OpenCode repair policy handles no response, not attachment feature |
| Unsupported image MIME like SVG | block before OpenCode bridge |
| Data URL too large | block before OpenCode API call |
Negative test for GLM 5.1
test('blocks GLM 5.1 image send before OpenCode prompt', async () => {
const result = planner.canPrepare({ runtimeKind: 'opencode', modelId: 'openrouter/z-ai/glm-5.1' }, [image]);
expect(result.allowed).toBe(false);
expect(result.blockers[0].code).toBe('attachment_model_vision_unsupported');
});
Positive test for Kimi
test('allows Kimi K2.6 image send', async () => {
const result = planner.canPrepare({ runtimeKind: 'opencode', modelId: 'openrouter/moonshotai/kimi-k2.6' }, [image]);
expect(result.allowed).toBe(true);
});
Regression traps
- Making unknown OpenCode models permissive by default.
- Marking ledger
deliveredjust because OpenCode accepted a file part. - Adding model-specific prompt text like “you can see image” instead of capability gating.
- Writing OpenRouter API key into logs while running live smoke.
- Updating app-managed OpenCode auth store from ephemeral env without explicit user action.
File-by-file implementation plan
claude_team
Potential files:
src/features/agent-attachments/core/domain/OpenCodeVisionCapability.ts
src/features/agent-attachments/main/adapters/output/OpenCodeAttachmentAdapter.ts
src/main/services/team/TeamProvisioningService.ts
src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts
src/renderer/components/team/messages/MessageComposer.tsx
Do not place the capability map inside TeamProvisioningService.
agent_teams_orchestrator
Potential files:
src/services/opencode/OpenCodeSessionBridge.ts
src/services/opencode/OpenCodeBridgeCommandHandler.ts
src/services/opencode/OpenCodeDeliveryResponseObserver.ts
src/services/opencode/*.test.ts
Bridge accepts structured parts. Observer/proof logic should remain unchanged except diagnostics may include attachment context.
Capability map structure
Use a compact data file or pure module.
const OPENCODE_VISION_CAPABILITY_OVERRIDES: Record<string, VisionCapability> = {
'openai/gpt-5.4-mini': { kind: 'supported', source: 'curated' },
'openrouter/moonshotai/kimi-k2.6': { kind: 'supported', source: 'curated' },
'openrouter/z-ai/glm-4.5v': { kind: 'supported', source: 'curated' },
'openrouter/z-ai/glm-5.1': {
kind: 'unsupported',
source: 'curated',
reason: 'Live smoke returned model response saying image input is unsupported.',
},
};
Heuristics only after exact overrides:
if (/\b(vl|vision|image)\b/i.test(modelId)) {
return { kind: 'supported', source: 'provider-metadata' };
}
If no exact/heuristic match:
return { kind: 'unknown', reason: 'No verified vision capability for this model.' };
UI state transitions
| State | UI |
|---|---|
| supported | normal send enabled |
| unsupported | send disabled with model-specific message |
| unknown | send disabled with not verified message |
| provider missing | send disabled with provider connect message |
| provider quota/auth error after send | runtime error notification/advisory, not preflight warning |
Direct teammate delivery with attachments
For OpenCode secondary direct user message:
user -> OpenCode member
The send path must:
- resolve member provider/model from live config/meta;
- run attachment capability gate;
- prepare OpenCode file parts;
- call bridge with text + file parts;
- keep existing ledger proof semantics.
Do not let UI-provided model labels drive capability. Backend must resolve actual member model.
Peer/lead relay with attachments
For v1, be conservative.
If user sends attachment to lead and lead delegates to OpenCode member, do not automatically forward original binary attachment unless a later phase explicitly implements attachment relay. The lead can describe or ask user to send directly.
This avoids accidental broad binary propagation across agents.
More detailed tests
Capability resolver
it.each([
['openrouter/moonshotai/kimi-k2.6', 'supported'],
['openrouter/z-ai/glm-4.5v', 'supported'],
['openrouter/z-ai/glm-5.1', 'unsupported'],
['openrouter/minimax/minimax-m2.5', 'unknown'],
])('resolves %s', (modelId, expected) => {
expect(resolveOpenCodeVisionCapability(modelId).kind).toBe(expected);
});
Bridge part validation
expect(() => assertOpenCodeBridgePromptPart({
type: 'file',
mime: 'image/png',
url: 'not-a-data-url',
filename: 'x.png',
})).toThrow('Invalid OpenCode file part data URL');
Ledger preservation
it('does not mark delivery successful only because file part was accepted', async () => {
const result = await delivery.sendWithAttachment(...);
expect(result.delivered).toBe(false);
expect(result.responsePending).toBe(true);
});
Review checklist
- Unknown OpenCode model does not receive image by default.
- GLM 5.1 image send is blocked before API call.
- Kimi K2.6 and GLM 4.5V are allowed.
- Text-only sends to all models still work.
- OpenCode provider errors are preserved exactly.
- Ledger/proof semantics unchanged.
- No OpenRouter key printed in live test logs.
Phase 4 exit criteria
Phase 4 is complete only when:
- text-only OpenCode delivery path is unchanged;
- OpenCode image send is allowed only for supported/verified vision models;
- GLM 5.1 image send is blocked before OpenCode call;
- Kimi K2.6 and GLM 4.5V image sends serialize to file parts in tests;
- OpenCode delivery proof semantics are unchanged;
- provider auth/quota/model errors remain exact;
- no automatic live probe runs on normal send.
Cross-repo sequencing
Recommended order:
- Orchestrator: extend OpenCode bridge to accept typed
partswhile preserving text-only API. - Orchestrator: test file part serialization with fake client.
- Desktop: add OpenCode capability resolver.
- Desktop: add OpenCode adapter.
- Desktop: wire direct OpenCode secondary attachment path.
- Add UI warnings/blockers for unsupported/unknown models.
- Run live smoke only after unit/fixture tests pass.
Backward-compatible bridge API
Keep old text API as wrapper.
async sendPromptText(input: { sessionId: string; text: string }) {
return this.sendPromptParts({
sessionId: input.sessionId,
parts: [{ type: 'text', text: input.text }],
});
}
New API:
async sendPromptParts(input: { sessionId: string; parts: OpenCodeBridgePromptPart[] }) {
validateParts(input.parts);
return this.client.sendSessionMessage(input.sessionId, { parts: input.parts });
}
Capability resolver detail
Return both machine code and user copy.
export interface OpenCodeVisionCapabilityDecision {
kind: 'supported' | 'unsupported' | 'unknown';
code:
| 'opencode_vision_supported'
| 'opencode_model_text_only'
| 'opencode_model_vision_unknown';
source: 'curated' | 'heuristic' | 'provider-metadata';
userMessage?: string;
diagnostic: string;
}
This prevents UI from string-matching diagnostics.
Provider auth vs capability
Do not require provider auth to decide static capability. A model can be known vision-capable even if auth is missing. Send still fails/preflights on auth separately.
Example:
openrouter/moonshotai/kimi-k2.6 capability = supported
OpenRouter auth missing = provider setup blocker
Both may appear in UI, but auth blocker is operational and capability is model-level.
More OpenCode tests
it('keeps text-only API as wrapper around parts API', async () => {
await bridge.sendPromptText({ sessionId: 's', text: 'hello' });
expect(client.sendSessionMessage).toHaveBeenCalledWith('s', {
parts: [{ type: 'text', text: 'hello' }],
});
});
it('serializes image file part without changing response observer', async () => {
await bridge.sendPromptParts({
sessionId: 's',
parts: [
{ type: 'text', text: 'what color?' },
{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,AAAA', filename: 'x.png' },
],
});
expect(observer).not.toHaveNewSuccessRule();
});
More OpenCode bug traps
| Trap | Prevention |
|---|---|
| unknown model allowed and silently fails | unknown blocks by default |
| bridge accepts arbitrary part JSON | strict union validation |
| file part accepted marks ledger delivered | tests assert proof unchanged |
| provider key logged in smoke | redactor in smoke script |
| model capability string hardcoded in UI only | backend resolver is source of truth |
| OpenRouter model id normalization inconsistent | shared normalizeOpenCodeModelRef tests |