# 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: ```text 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: ```json { "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: ```text 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. ```ts 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 ```ts 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. ```ts export interface OpenCodePromptPart { type: 'text' | 'file'; text?: string; mime?: string; url?: string; filename?: string; } export interface SendOpenCodePromptInput { sessionId: string; parts: OpenCodePromptPart[]; } ``` When no attachments: ```ts parts: [{ type: 'text', text: params.text }] ``` When attachments: ```ts 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_send` with correct relay id; - work-sync report; - task/progress evidence. Attachment accepted by OpenCode is not response proof. ## UI capability behavior Examples: ```text GLM 5.1 does not support image input. Choose GLM 4.5V, Kimi K2.6, GPT-5.4-mini, Claude, or Codex. ``` ```text Image input support for this OpenCode model is not verified. Choose a verified vision model before sending screenshots. ``` ```text 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: ```text 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.6` is supported; - `openrouter/z-ai/glm-4.5v` is supported; - `openrouter/z-ai/glm-5.1` is unsupported; - unknown OpenRouter model is blocked for image; - OpenCode adapter emits `file` part 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: ```bash 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: ```text 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. ```ts 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 ```ts 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: ```text openrouter/moonshotai/kimi-k2.6 moonshotai/kimi-k2.6 via openrouter provider context z-ai/glm-4.5v ``` Normalize consistently: ```ts 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. ```ts 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. ```text 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: ```text 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: ```text Image support for this OpenCode model is not verified. Send text only or choose a verified vision model. ``` For missing provider auth: ```text 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 ```ts 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 ```ts 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 `delivered` just 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: ```text 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: ```text 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. ```ts const OPENCODE_VISION_CAPABILITY_OVERRIDES: Record = { '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: ```ts if (/\b(vl|vision|image)\b/i.test(modelId)) { return { kind: 'supported', source: 'provider-metadata' }; } ``` If no exact/heuristic match: ```ts 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: ```text user -> OpenCode member ``` The send path must: 1. resolve member provider/model from live config/meta; 2. run attachment capability gate; 3. prepare OpenCode file parts; 4. call bridge with text + file parts; 5. 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 ```ts 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 ```ts expect(() => assertOpenCodeBridgePromptPart({ type: 'file', mime: 'image/png', url: 'not-a-data-url', filename: 'x.png', })).toThrow('Invalid OpenCode file part data URL'); ``` ### Ledger preservation ```ts 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: 1. Orchestrator: extend OpenCode bridge to accept typed `parts` while preserving text-only API. 2. Orchestrator: test file part serialization with fake client. 3. Desktop: add OpenCode capability resolver. 4. Desktop: add OpenCode adapter. 5. Desktop: wire direct OpenCode secondary attachment path. 6. Add UI warnings/blockers for unsupported/unknown models. 7. Run live smoke only after unit/fixture tests pass. ## Backward-compatible bridge API Keep old text API as wrapper. ```ts async sendPromptText(input: { sessionId: string; text: string }) { return this.sendPromptParts({ sessionId: input.sessionId, parts: [{ type: 'text', text: input.text }], }); } ``` New API: ```ts 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. ```ts 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: ```text 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 ```ts 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' }], }); }); ``` ```ts 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 |