agent-ecosystem/docs/team-management/agent-attachments-phase-4-opencode-vision-plan.md

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_send with 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.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:

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 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:

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:

  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

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:

  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.

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