diff --git a/src/providers/opencode/renderer/adapters/codeEdit.test.ts b/src/providers/opencode/renderer/adapters/codeEdit.test.ts
new file mode 100644
index 00000000..8b7895e6
--- /dev/null
+++ b/src/providers/opencode/renderer/adapters/codeEdit.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+
+import { fromOpencodeApplyPatch } from '@providers/opencode/renderer/adapters/codeEdit'
+
+describe('OpenCode apply_patch adapter', () => {
+ it('admits parseable direct patchText payloads', () => {
+ expect(fromOpencodeApplyPatch({
+ type: 'tool_use',
+ id: 'patch-1',
+ name: 'apply_patch',
+ input: {
+ patchText: '*** Begin Patch\n*** Update File: src/example.ts\n@@\n-old\n+new\n*** End Patch',
+ },
+ })).toEqual(expect.objectContaining({
+ label: 'apply_patch',
+ status: 'running',
+ partial: false,
+ }))
+ })
+
+ it('declines malformed patch bodies back to the generic fallback', () => {
+ expect(fromOpencodeApplyPatch({
+ type: 'tool_use',
+ id: 'patch-2',
+ name: 'apply_patch',
+ input: { patchText: '*** Begin Patch\nnot enough structure yet' },
+ })).toBeNull()
+ })
+})
diff --git a/src/providers/opencode/renderer/adapters/codeEdit.ts b/src/providers/opencode/renderer/adapters/codeEdit.ts
new file mode 100644
index 00000000..101bd6bd
--- /dev/null
+++ b/src/providers/opencode/renderer/adapters/codeEdit.ts
@@ -0,0 +1,20 @@
+import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript'
+import type { CodeEditRenderModel } from '@providers/shared/renderer/protocols/code-edit/model'
+import {
+ codeEditModelFromDirectApplyPatchText,
+ rawDirectApplyPatchText,
+} from '@providers/shared/renderer/protocols/code-edit/applyPatch'
+
+export function fromOpencodeApplyPatch(
+ block: ToolUseBlock,
+ opts: { streaming?: boolean; result?: ToolResultBlock | null } = {},
+): CodeEditRenderModel | null {
+ if (block.name !== 'apply_patch') return null
+ const rawPatch = rawDirectApplyPatchText(block.input)
+ if (!rawPatch.includes('*** Begin Patch')) return null
+ return codeEditModelFromDirectApplyPatchText(rawPatch, opts)
+}
+
+export function rawOpencodeApplyPatchText(block: ToolUseBlock): string {
+ return rawDirectApplyPatchText(block.input)
+}
diff --git a/src/providers/opencode/renderer/components/apply-patch/index.tsx b/src/providers/opencode/renderer/components/apply-patch/index.tsx
new file mode 100644
index 00000000..6973dd76
--- /dev/null
+++ b/src/providers/opencode/renderer/components/apply-patch/index.tsx
@@ -0,0 +1,39 @@
+import { memo } from 'react'
+
+import type { CodeEditRenderModel } from '@providers/shared/renderer/protocols/code-edit/model'
+import { CodeEditView } from '@providers/shared/renderer/protocols/code-edit/CodeEditView'
+import { PagedTextViewer } from '@renderer/lib/text/PagedTextViewer'
+
+const CODE_EDIT_VIEW_OPERATION_CAP = 24
+
+export const OpencodeApplyPatchRow = memo(function OpencodeApplyPatchRow({
+ model,
+ rawPatch,
+}: {
+ model: CodeEditRenderModel
+ rawPatch: string
+}) {
+ const totalFiles = model.totalFiles ?? model.files.length
+ const previewIncomplete =
+ model.files.length > CODE_EDIT_VIEW_OPERATION_CAP ||
+ model.filesTruncated === true ||
+ model.fileCountTruncated === true ||
+ totalFiles > model.files.length ||
+ model.files.some(file => file.previewTruncated === true)
+
+ return (
+
+
+ {previewIncomplete ? (
+
+
+ Rich preview is partial · view exact paged patch
+
+
+
+ ) : null}
+
+ )
+})
diff --git a/src/providers/opencode/renderer/rows/dispatch.tsx b/src/providers/opencode/renderer/rows/dispatch.tsx
index d86158ab..7642474c 100644
--- a/src/providers/opencode/renderer/rows/dispatch.tsx
+++ b/src/providers/opencode/renderer/rows/dispatch.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript'
+import { fromOpencodeApplyPatch, rawOpencodeApplyPatchText } from '@providers/opencode/renderer/adapters/codeEdit'
+import { OpencodeApplyPatchRow } from '@providers/opencode/renderer/components/apply-patch'
import { renderOpencodeReadResult } from '@providers/opencode/renderer/components/read-result'
import { fromOpencodeTodoUse } from '@providers/opencode/renderer/adapters/todo'
import { OpencodeTodoRow } from '@providers/opencode/renderer/components/todo'
@@ -64,6 +66,10 @@ export function renderOpencodeOperation(
// Specialized rows (diff-style edit rendering etc.) should be added here one
// evidence-backed tool at a time, not speculatively.
function renderOpencodeToolUse(block: ToolUseBlock): ReactNode | undefined {
+ const applyPatch = fromOpencodeApplyPatch(block)
+ if (applyPatch) {
+ return
+ }
const todo = fromOpencodeTodoUse(block)
return todo ? : undefined
}
diff --git a/src/providers/opencode/renderer/semantic/dispatch.renderer.test.tsx b/src/providers/opencode/renderer/semantic/dispatch.renderer.test.tsx
new file mode 100644
index 00000000..71058220
--- /dev/null
+++ b/src/providers/opencode/renderer/semantic/dispatch.renderer.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+
+import { renderOpencodeSemanticBlock } from '@providers/opencode/renderer/semantic/dispatch'
+import type { SemanticLiveBlock } from '@renderer/session-runtime/state'
+import type { ToolResultBlock } from '@shared/types/transcript'
+
+const context = {
+ committedToolResults: new Map(),
+}
+
+function block(overrides: Partial): SemanticLiveBlock {
+ return {
+ blockIndex: 0,
+ kind: 'tool_use',
+ finalized: true,
+ ...overrides,
+ }
+}
+
+describe('OpenCode semantic provider boundary', () => {
+ it('owns proven live todo and Git bash shapes through provider dispatch', () => {
+ expect(renderOpencodeSemanticBlock(block({
+ toolName: 'todowrite',
+ toolUseId: 'todo-live',
+ parsedInput: {
+ todos: [{ content: 'Verify renderer', status: 'in_progress', priority: 'high' }],
+ },
+ }), context)).toEqual(expect.objectContaining({
+ action: 'render',
+ receipt: { rendererId: 'opencode.rows.dispatch' },
+ }))
+
+ expect(renderOpencodeSemanticBlock(block({
+ toolName: 'bash',
+ toolUseId: 'bash-live',
+ parsedInput: {
+ command: 'git status --short',
+ description: 'Shows repository changes',
+ timeout: 120000,
+ workdir: '/workspace/project',
+ },
+ }), context)).toEqual(expect.objectContaining({
+ action: 'render',
+ receipt: { rendererId: 'shared.command', protocolId: 'command.git' },
+ }))
+ })
+
+ it('declines generic live tool shapes back to the shared semantic fallback', () => {
+ expect(renderOpencodeSemanticBlock(block({
+ toolName: 'grep',
+ toolUseId: 'grep-live',
+ parsedInput: {
+ pattern: 'renderSemanticBlock',
+ path: '/workspace/project/src',
+ include: '*.ts',
+ },
+ }), context)).toEqual({ action: 'fallback' })
+ })
+
+ it('keeps live read on a provider-owned result path even though the invocation stays generic', () => {
+ expect(renderOpencodeSemanticBlock(block({
+ toolName: 'read',
+ toolUseId: 'read-live',
+ parsedInput: {
+ filePath: '/workspace/project/src/example.ts',
+ offset: 1,
+ limit: 2,
+ },
+ resultAt: 1,
+ resultContent: '/workspace/project/src/example.ts\nfile\n\n1: export const answer = 42\n2: export type Answer = number\n',
+ }), context)).toEqual(expect.objectContaining({
+ action: 'render',
+ receipt: { rendererId: 'opencode.rows.dispatch' },
+ }))
+ })
+
+ it('owns live apply_patch once the direct patchText closes a real file header', () => {
+ expect(renderOpencodeSemanticBlock(block({
+ toolName: 'apply_patch',
+ toolUseId: 'patch-live',
+ parsedInput: {
+ patchText: '*** Begin Patch\n*** Update File: src/example.ts\n@@\n-old\n+new\n*** End Patch',
+ },
+ }), context)).toEqual(expect.objectContaining({
+ action: 'render',
+ receipt: { rendererId: 'opencode.rows.dispatch' },
+ }))
+ })
+})
diff --git a/src/providers/opencode/renderer/semantic/dispatch.tsx b/src/providers/opencode/renderer/semantic/dispatch.tsx
new file mode 100644
index 00000000..c4933218
--- /dev/null
+++ b/src/providers/opencode/renderer/semantic/dispatch.tsx
@@ -0,0 +1,98 @@
+import { fromOpencodeApplyPatch, rawOpencodeApplyPatchText } from '@providers/opencode/renderer/adapters/codeEdit'
+import { OpencodeApplyPatchRow } from '@providers/opencode/renderer/components/apply-patch'
+import { renderOpencodeReadResult } from '@providers/opencode/renderer/components/read-result'
+import { renderOpencodeOperation } from '@providers/opencode/renderer/rows/dispatch'
+import { JsonToolRow } from '@providers/shared/renderer/rows/JsonToolRow'
+import { renderLiveProviderTool } from '@providers/shared/renderer/rows/LiveProviderToolRow'
+import type { SemanticLiveBlock } from '@renderer/session-runtime/state'
+import type { ProviderSemanticDecision } from '@shared/types/providerConfig'
+import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript'
+
+function semanticId(block: SemanticLiveBlock): string {
+ return block.toolUseId ?? block.callId ?? block.itemId ?? `live:${block.blockIndex}`
+}
+
+function opencodeSemanticTool(block: SemanticLiveBlock): ToolUseBlock | null {
+ if (block.kind !== 'tool_use' || !block.toolName || !block.parsedInput) return null
+ return {
+ type: 'tool_use',
+ id: semanticId(block),
+ name: block.toolName,
+ input: block.parsedInput,
+ }
+}
+
+function semanticResult(block: SemanticLiveBlock, toolUseId: string): ToolResultBlock | null {
+ if (block.resultAt == null && block.resultContent == null) return null
+ return {
+ type: 'tool_result',
+ tool_use_id: toolUseId,
+ content: block.resultContent ?? '',
+ ...(block.resultIsError === true ? { is_error: true } : {}),
+ }
+}
+
+/** OpenCode live semantic dispatch.
+ *
+ * WHY this starts with finalized parsed inputs only: the retained OpenCode
+ * evidence proves committed tool families first, while live SSE ownership is
+ * only trustworthy once the reducer has the exact parsed input object that the
+ * provider emitted. Claiming by tool name alone would repeat the PTY-era
+ * mistake of letting transport vocabulary outrun reviewed structure. */
+export function renderOpencodeSemanticBlock(
+ block: SemanticLiveBlock,
+ context: {
+ committedToolResults: ReadonlyMap
+ },
+): ProviderSemanticDecision | undefined {
+ const tool = opencodeSemanticTool(block)
+ if (!tool) return undefined
+
+ const result = semanticResult(block, tool.id)
+
+ // WHY `read` needs a provider-owned live branch even though its invocation
+ // remains generic: OpenCode's durable renderer specializes the RESULT body,
+ // not the request row. `renderLiveProviderTool` intentionally refuses to own
+ // a semantic block when the invocation route is fallback-only, because that is
+ // the safe default for most providers. For OpenCode read we already have a
+ // reviewed result parser (`…` tag soup → code slab), so the
+ // honest live UX is "generic invocation + owned result" rather than dropping
+ // the whole pair back to the shared semantic fallback.
+ if (tool.name === 'read' && result) {
+ const ownedResult = renderOpencodeReadResult(result)
+ if (ownedResult) {
+ return {
+ action: 'render',
+ receipt: { rendererId: 'opencode.rows.dispatch' },
+ node: (
+
+
+ {ownedResult}
+
+ ),
+ }
+ }
+ }
+
+ const applyPatch = fromOpencodeApplyPatch(tool, {
+ streaming: block.finalized !== true,
+ result,
+ })
+ if (applyPatch) {
+ return {
+ action: 'render',
+ receipt: { rendererId: 'opencode.rows.dispatch' },
+ node: ,
+ }
+ }
+
+ return renderLiveProviderTool({
+ tool,
+ finalized: block.finalized === true,
+ resultPresent: result !== null,
+ resultContent: block.resultContent ?? '',
+ resultIsError: block.resultIsError === true,
+ committedResults: context.committedToolResults,
+ renderOperation: renderOpencodeOperation,
+ })
+}
diff --git a/src/providers/opencode/renderer/shapes.ts b/src/providers/opencode/renderer/shapes.ts
index 391bbb18..1ae6116b 100644
--- a/src/providers/opencode/renderer/shapes.ts
+++ b/src/providers/opencode/renderer/shapes.ts
@@ -1,19 +1,351 @@
-import { defineRenderShapeCatalog } from '@renderer/rendering/evidence/defineRenderShape'
+import {
+ defineRenderShape,
+ defineRenderShapeCatalog,
+} from '@renderer/rendering/evidence/defineRenderShape'
// OpenCode render-shape catalog (Phase 4, PR #555).
//
-// DELIBERATELY EMPTY at seeding time, and that emptiness is evidence, not
-// an omission: the 48-bundle corpus's three opencode bundles are all
-// empty-shell fixtures (entries: [], zero blocks — they exercise streaming
-// shell states, not tool shapes), so there is no observed opencode wire
-// structure to catalog yet. The plan forbids inventing entries from an
-// unobserved tool list ("no renderer invented from an unobserved tool
-// list", Phase 4 gate). First real opencode capture soak → first entries
-// land here through the Unknown Shape Inbox loop.
-// TODO(condition-shape-soak): the same rule applies to OpenCode permission and
-// question conditions. Policy proves where known kinds render; it does not
-// prove the exact payload shape. Keep first live sightings unknown until a real
-// SSE capture is extracted and reviewed.
-export const OPENCODE_RENDER_SHAPES = defineRenderShapeCatalog('opencode', {})
+// The initial PR #555 seed left this empty on purpose because the frozen bundle
+// corpus had only empty-shell OpenCode fixtures. That is no longer true: the
+// local 2026-07-19 OpenCode recording soak produced a first stable committed
+// tool corpus through the exact Unknown Shape Inbox loop the plan requires.
+//
+// What is deliberately STILL absent here: semantic-tool and condition-plane
+// entries. We now have the raw SSE events for those paths, but not retained
+// render-shape sightings yet. Keeping them out of the catalog preserves the
+// core PR #555 invariant: policy or raw transport knowledge is not permission
+// to invent a reviewed shape entry. Committed tool-use/result is the first
+// honest slice because the recorder already proved those exact fingerprints and
+// routes in `__render_shape` sidecars.
+export const OPENCODE_RENDER_SHAPES = defineRenderShapeCatalog('opencode', {
+ 'opencode.semantic.apply-patch.v1': defineRenderShape({
+ id: 'opencode.semantic.apply-patch.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-9003dc12'],
+ eventTypes: ['tool_use'],
+ planes: ['semantic-tool'] as const,
+ lifecycles: ['input-complete'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/apply-patch/semantic-final.json'], prefixes: [] },
+ disposition: { kind: 'specialized', rendererId: 'opencode.rows.dispatch' },
+ alternateDispositions: [
+ {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'A live apply_patch body without a parseable file header still stays visible on the generic semantic fallback.',
+ },
+ ],
+ why: 'OpenCode semantic apply_patch now routes through the provider-owned code-edit wrapper once the finalized patchText closes a real file header. Non-parseable bodies still decline to the shared semantic fallback.',
+ }),
+ 'opencode.semantic.bash.v1': defineRenderShape({
+ id: 'opencode.semantic.bash.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-41fff07e'],
+ eventTypes: ['tool_use'],
+ planes: ['semantic-tool'] as const,
+ lifecycles: ['input-complete'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/bash/semantic-final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'A finalized bash invocation is still only a shell command until the content-dependent Git classifier proves the shared command route.',
+ },
+ alternateDispositions: [
+ { kind: 'specialized', rendererId: 'shared.command', protocolId: 'command.git' },
+ ],
+ why: 'OpenCode semantic bash mirrors the committed content-dependent split: generic for ordinary shell commands, shared command protocol for proved Git operations.',
+ }),
+ 'opencode.semantic.glob.v1': defineRenderShape({
+ id: 'opencode.semantic.glob.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-f879ddcb'],
+ eventTypes: ['tool_use'],
+ planes: ['semantic-tool'] as const,
+ lifecycles: ['input-complete'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/glob/semantic-final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'OpenCode live glob is a plain structured tool invocation with no richer owned semantic view proven yet.',
+ },
+ why: 'Catalogued so the first OpenCode live glob shape does not remain uncatalogued the moment this branch records semantic-tool sightings.',
+ }),
+ 'opencode.semantic.grep.v1': defineRenderShape({
+ id: 'opencode.semantic.grep.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-6507bf37'],
+ eventTypes: ['tool_use'],
+ planes: ['semantic-tool'] as const,
+ lifecycles: ['input-complete'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/grep/semantic-final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'OpenCode live grep is still just a structured search invocation and should stay generic until a richer provider-owned semantic route is proven.',
+ },
+ why: 'Catalogued proactively from the reducer-owned semantic shape so live OpenCode grep does not become an unknown the first time this branch records it.',
+ }),
+ 'opencode.semantic.read.v1': defineRenderShape({
+ id: 'opencode.semantic.read.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-1549305d', 'fp2-2140fe57', 'fp2-a7cc5373'],
+ eventTypes: ['tool_use'],
+ planes: ['semantic-tool'] as const,
+ lifecycles: ['input-complete', 'prefix'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: {
+ final: ['rendering-shapes/opencode/read/semantic-final.json'],
+ prefixes: ['rendering-shapes/opencode/read/semantic-prefix.json'],
+ },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'The request row itself stays generic; the provider-owned live path is earned by the paired tagged read result, not by the invocation alone.',
+ },
+ alternateDispositions: [
+ { kind: 'specialized', rendererId: 'opencode.rows.dispatch' },
+ ],
+ why: 'OpenCode live read has three honest milestones in the current reducer shape: a prefix while input is still incomplete, a finalized invocation with no result yet, and a provider-owned row once the tagged read result arrives. One catalog entry owns that family with finite alternate routes.',
+ }),
+ 'opencode.semantic.skill.v1': defineRenderShape({
+ id: 'opencode.semantic.skill.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-66bcff9c'],
+ eventTypes: ['tool_use'],
+ planes: ['semantic-tool'] as const,
+ lifecycles: ['input-complete'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/skill/semantic-final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'The current skill live shape carries only a simple name payload and therefore remains on the shared structured semantic fallback.',
+ },
+ why: 'Catalogued so the first OpenCode live skill invocation is a reviewed generic semantic family instead of an uncatalogued transport shape.',
+ }),
+ 'opencode.semantic.todowrite.v1': defineRenderShape({
+ id: 'opencode.semantic.todowrite.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-dc7d1b61'],
+ eventTypes: ['tool_use'],
+ planes: ['semantic-tool'] as const,
+ lifecycles: ['input-complete'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/todowrite/semantic-final.json'], prefixes: [] },
+ disposition: { kind: 'specialized', rendererId: 'opencode.rows.dispatch' },
+ why: 'The same evidence-backed checklist grammar that owns the committed OpenCode invocation now also owns its live semantic finalized form.',
+ }),
+ 'opencode.tool-result.tool-result.v1': defineRenderShape({
+ id: 'opencode.tool-result.tool-result.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-f31bc9d2'],
+ eventTypes: ['tool_result'],
+ planes: ['committed-tool-result'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/tool-result/final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'The base OpenCode result envelope is just text + error state until the source tool proves a richer owned result view.',
+ },
+ alternateDispositions: [
+ { kind: 'specialized', rendererId: 'opencode.rows.dispatch' },
+ ],
+ why: 'OpenCode committed results all share one plain tool_result envelope today. The route depends on the paired tool: read can graduate to a provider-owned result slab, while everything else stays on the shared generic fallback.',
+ }),
+ 'opencode.tool-use.bash.v1': defineRenderShape({
+ id: 'opencode.tool-use.bash.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-f0f0a673'],
+ eventTypes: ['tool_use'],
+ planes: ['committed-tool-use'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/bash/committed.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'A bash invocation is not inherently a Git operation; unclassified commands must stay visible on the generic row.',
+ },
+ alternateDispositions: [
+ { kind: 'specialized', rendererId: 'shared.command', protocolId: 'command.git' },
+ ],
+ why: 'OpenCode bash uses the same finite content-dependent split as the other providers: ordinary shell commands stay generic, while proved Git commands route through the shared command protocol.',
+ }),
+ 'opencode.tool-use.apply-patch.v1': defineRenderShape({
+ id: 'opencode.tool-use.apply-patch.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-ad389f5c'],
+ eventTypes: ['tool_use'],
+ planes: ['committed-tool-use'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/apply-patch/final.json'], prefixes: [] },
+ disposition: { kind: 'specialized', rendererId: 'opencode.rows.dispatch' },
+ alternateDispositions: [
+ {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'A named apply_patch envelope without a parseable file header remains visible instead of inventing an empty edit.',
+ },
+ ],
+ why: 'OpenCode direct apply_patch now owns the same shared code-edit preview contract as the other providers once a real file header closes. Malformed or preamble-only bodies still decline honestly to the generic fallback.',
+ }),
+ 'opencode.tool-use.glob.v1': defineRenderShape({
+ id: 'opencode.tool-use.glob.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-edb07b48'],
+ eventTypes: ['tool_use'],
+ planes: ['committed-tool-use'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/glob/final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'The OpenCode glob invocation grammar is already legible on the shared structured tool row and no richer provider-owned view is proven yet.',
+ },
+ why: 'This is a straight committed tool-use shape with no paired provider-owned result behavior today; the shared structured row is the honest rendering contract.',
+ }),
+ 'opencode.tool-use.grep.v1': defineRenderShape({
+ id: 'opencode.tool-use.grep.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-00666fa5'],
+ eventTypes: ['tool_use'],
+ planes: ['committed-tool-use'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/grep/final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'OpenCode grep emits a plain structured query payload with no distinct owned visualization proved yet.',
+ },
+ why: 'Like glob, grep already reads correctly through the shared structured tool grammar and should not graduate until evidence proves a richer provider-owned route.',
+ }),
+ 'opencode.tool-use.read.v1': defineRenderShape({
+ id: 'opencode.tool-use.read.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-56deb4a5'],
+ eventTypes: ['tool_use'],
+ planes: ['committed-tool-use'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/read/final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'The invocation itself is just file-path pagination input; the richer evidence lives on the paired result renderer, not the request row.',
+ },
+ why: 'OpenCode read keeps the same split as Claude/Codex: the request row stays a plain structured tool invocation, while the paired result may own a specialized file/document presentation.',
+ }),
+ 'opencode.tool-use.skill.v1': defineRenderShape({
+ id: 'opencode.tool-use.skill.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-5b292d58'],
+ eventTypes: ['tool_use'],
+ planes: ['committed-tool-use'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/skill/final.json'], prefixes: [] },
+ disposition: {
+ kind: 'generic',
+ rendererId: 'shared.generic-tool',
+ reason: 'The current OpenCode skill invocation evidence is only a simple name payload; that is not enough to justify a provider-owned row.',
+ },
+ why: 'This shape exists in the committed corpus and must be catalogued so the evidence loop closes, but the UI contract is still the shared structured fallback until richer behavior is observed.',
+ }),
+ 'opencode.tool-use.todowrite.v1': defineRenderShape({
+ id: 'opencode.tool-use.todowrite.v1',
+ provider: 'opencode',
+ fingerprints: ['fp2-9e620a1d'],
+ eventTypes: ['tool_use'],
+ planes: ['committed-tool-use'] as const,
+ lifecycles: ['durable'] as const,
+ observed: {
+ providerVersions: [],
+ models: [],
+ firstSeen: '2026-07-19',
+ lastSeen: '2026-07-19',
+ },
+ fixtures: { final: ['rendering-shapes/opencode/todowrite/final.json'], prefixes: [] },
+ disposition: { kind: 'specialized', rendererId: 'opencode.rows.dispatch' },
+ why: 'The checklist grammar is fully parseable from the committed invocation payload and already has a provider-owned row. Cataloguing it turns the existing evidence-backed specialization into a reviewed permanent promise.',
+ }),
+})
export type OpencodeRenderShapeId = keyof typeof OPENCODE_RENDER_SHAPES
diff --git a/src/providers/opencode/runtime/opencodeSession.readiness.test.ts b/src/providers/opencode/runtime/opencodeSession.readiness.test.ts
index 1d4a2976..b33bcba0 100644
--- a/src/providers/opencode/runtime/opencodeSession.readiness.test.ts
+++ b/src/providers/opencode/runtime/opencodeSession.readiness.test.ts
@@ -1,8 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ConditionCustomAction } from '@shared/types/providerConditions'
+
const headlessControl = vi.hoisted(() => ({
exitDuringStart: false,
stop: vi.fn(async (): Promise => {}),
+ permissionReply: vi.fn(async (): Promise => {}),
+ rejectQuestion: vi.fn(async (): Promise => {}),
+ lastInstance: null as null | {
+ screen: import('node:events').EventEmitter
+ },
}))
vi.mock('opencode-headless', async () => {
@@ -12,6 +19,14 @@ vi.mock('opencode-headless', async () => {
readonly screen = new EventEmitter()
readonly committed = new EventEmitter()
readonly semantic = new EventEmitter()
+ readonly permissionService = {
+ reply: headlessControl.permissionReply,
+ }
+ rejectQuestion = headlessControl.rejectQuestion
+ constructor() {
+ super()
+ headlessControl.lastInstance = this
+ }
async start(): Promise {
if (headlessControl.exitDuringStart) this.emit('exit', { exitCode: 17 })
}
@@ -28,6 +43,9 @@ describe('OpencodeSession composer readiness', () => {
beforeEach(() => {
headlessControl.exitDuringStart = false
headlessControl.stop.mockClear()
+ headlessControl.permissionReply.mockClear()
+ headlessControl.rejectQuestion.mockClear()
+ headlessControl.lastInstance = null
})
it('becomes ready only after headless startup and history publication finish', async () => {
@@ -57,4 +75,93 @@ describe('OpencodeSession composer readiness', () => {
expect(started).not.toHaveBeenCalled()
expect(headlessControl.stop).toHaveBeenCalledTimes(1)
})
+
+ it('publishes and resolves permission conditions through the HTTP action path', async () => {
+ const session = new OpencodeSession({ cwd: '/tmp/project' })
+ const snapshots: Array = []
+ session.on('conditions', snapshot => snapshots.push(snapshot))
+
+ await session.start()
+ headlessControl.lastInstance?.screen.emit('permission', {
+ state: {
+ visible: true,
+ requestID: 'req-1',
+ title: 'write outside workspace',
+ metadata: { tool: 'write' },
+ },
+ })
+
+ expect(snapshots).toHaveLength(1)
+ expect(snapshots[0]).toMatchObject({
+ provider: 'opencode',
+ conditions: {
+ 'opencode.permission': {
+ kind: 'opencode.permission',
+ state: {
+ visible: true,
+ requestID: 'req-1',
+ title: 'write outside workspace',
+ },
+ },
+ },
+ })
+
+ const permission = (snapshots[0] as {
+ conditions: Record }>
+ }).conditions['opencode.permission']
+ const allowOnce = permission.actions[0] as ConditionCustomAction
+ expect(allowOnce).toMatchObject({
+ kind: 'custom',
+ name: 'opencode.permission.reply',
+ payload: { requestID: 'req-1', reply: 'once' },
+ })
+
+ await expect(session.resolveCondition(allowOnce)).resolves.toEqual({ ok: true })
+ expect(headlessControl.permissionReply).toHaveBeenCalledWith('req-1', 'once')
+ expect(snapshots[1]).toMatchObject({ provider: 'opencode', conditions: {} })
+ })
+
+ it('publishes and resolves question conditions through the reject-only HTTP action path', async () => {
+ const session = new OpencodeSession({ cwd: '/tmp/project' })
+ const snapshots: Array = []
+ session.on('conditions', snapshot => snapshots.push(snapshot))
+
+ await session.start()
+ headlessControl.lastInstance?.screen.emit('question', {
+ state: {
+ visible: true,
+ questionID: 'q-1',
+ text: 'Do you want to continue?',
+ },
+ })
+
+ expect(snapshots).toHaveLength(1)
+ expect(snapshots[0]).toMatchObject({
+ provider: 'opencode',
+ conditions: {
+ 'opencode.question': {
+ kind: 'opencode.question',
+ state: {
+ visible: true,
+ questionID: 'q-1',
+ text: 'Do you want to continue?',
+ },
+ },
+ },
+ })
+
+ const question = (snapshots[0] as {
+ conditions: Record }>
+ }).conditions['opencode.question']
+ const reject = question.actions[0] as ConditionCustomAction
+ expect(reject).toMatchObject({
+ kind: 'custom',
+ name: 'opencode.question.reject',
+ payload: { questionID: 'q-1' },
+ })
+
+ await expect(session.resolveCondition(reject)).resolves.toEqual({ ok: true })
+ expect(headlessControl.rejectQuestion).toHaveBeenCalledWith('q-1')
+ expect(snapshots[1]).toMatchObject({ provider: 'opencode', conditions: {} })
+ })
})
diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts
index be867d1f..bfd85783 100644
--- a/src/providers/registry.renderer.capabilities.ts
+++ b/src/providers/registry.renderer.capabilities.ts
@@ -50,6 +50,7 @@ import {
} from '@providers/opencode/renderer/transcript/mapper'
import { opencodeComposerSubmit } from '@providers/opencode/renderer/composerSubmit'
import { renderOpencodeOperation } from '@providers/opencode/renderer/rows/dispatch'
+import { renderOpencodeSemanticBlock } from '@providers/opencode/renderer/semantic/dispatch'
import { codexComposerSubmit } from '@providers/codex/renderer/composerSubmit'
import { CODEX_IDENTITY } from '@providers/codex/renderer/identity'
import {
@@ -306,6 +307,7 @@ const opencodeCapabilities: RendererProviderCapabilities = {
// a real todo list; everything else falls through to the generic rows.
renderOperation: renderOpencodeOperation,
classifyDurableEntry: () => null,
+ renderSemanticBlock: renderOpencodeSemanticBlock,
// opencode has no subagent-spawn tool yet (no fleet fanout on this backend).
isSpawnTool: () => false,
createTranscriptEntryMapper: () => createOpencodeTranscriptEntryMapper(),
diff --git a/src/providers/shared/renderer/protocols/code-edit/applyPatch.ts b/src/providers/shared/renderer/protocols/code-edit/applyPatch.ts
new file mode 100644
index 00000000..674d6159
--- /dev/null
+++ b/src/providers/shared/renderer/protocols/code-edit/applyPatch.ts
@@ -0,0 +1,131 @@
+import type { ToolResultBlock } from '@shared/types/transcript'
+import { boundedTextPage } from '@renderer/lib/text/boundedText'
+import type { DiffLine } from '@shared/parsers/lineDiff'
+import type {
+ CodeEditFile,
+ CodeEditRenderModel,
+} from '@providers/shared/renderer/protocols/code-edit/model'
+
+export type ApplyPatchFile = {
+ path: string
+ action: 'Add' | 'Update' | 'Delete'
+ movedTo?: string
+ lines: DiffLine[]
+}
+
+type ApplyPatchPreview = {
+ files: ApplyPatchFile[]
+ totalFiles: number
+ fileCountTruncated: boolean
+ patchClosed: boolean
+ previewTruncated: boolean
+}
+
+const VERB = { Add: 'Creating', Update: 'Editing', Delete: 'Deleting' } as const
+
+export function rawDirectApplyPatchText(input: unknown): string {
+ if (typeof input === 'string') return input
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return ''
+ const record = input as Record
+ return typeof record.patchText === 'string' ? record.patchText : ''
+}
+
+export function codeEditModelFromDirectApplyPatchText(
+ rawPatch: string,
+ opts: { streaming?: boolean; result?: ToolResultBlock | null } = {},
+): CodeEditRenderModel | null {
+ const preview = parseApplyPatchPreviewFromText(rawPatch)
+ const files = preview.files.map((file, index) => ({
+ path: file.movedTo ? `${file.path} → ${file.movedTo}` : file.path,
+ verb: file.movedTo ? 'Moving' : VERB[file.action],
+ lines: file.lines,
+ additions: file.lines.filter(line => line.kind === '+').length,
+ deletions: file.lines.filter(line => line.kind === '-').length,
+ previewTruncated: preview.previewTruncated && index === preview.files.length - 1,
+ countsTruncated: preview.previewTruncated && index === preview.files.length - 1,
+ streaming: opts.streaming === true,
+ }) satisfies CodeEditFile)
+ if (files.length === 0) return null
+
+ const failed = opts.result?.is_error === true
+ return {
+ label: 'apply_patch',
+ files,
+ totalFiles: preview.totalFiles,
+ fileCountTruncated: preview.fileCountTruncated,
+ status: failed
+ ? 'failure'
+ : opts.result
+ ? 'success'
+ : opts.streaming
+ ? 'streaming'
+ : 'running',
+ errorSummary: failed ? firstLine(opts.result) : undefined,
+ partial: opts.streaming === true || preview.previewTruncated || !preview.patchClosed,
+ }
+}
+
+function parseApplyPatchPreviewFromText(fullText: string): ApplyPatchPreview {
+ if (!fullText.includes('*** Begin Patch')) {
+ return {
+ files: [],
+ totalFiles: 0,
+ fileCountTruncated: false,
+ patchClosed: false,
+ previewTruncated: false,
+ }
+ }
+
+ const page = boundedTextPage(fullText)
+ const text = page.text
+ const endMatch = /^\*\*\* End Patch\r?$/m.exec(text)
+ const files: ApplyPatchFile[] = []
+ let current: ApplyPatchFile | null = null
+
+ for (const rawLine of text.split('\n')) {
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
+ if (line === '*** End Patch') break
+
+ const fileMatch = line.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/)
+ if (fileMatch) {
+ current = {
+ action: fileMatch[1] as ApplyPatchFile['action'],
+ path: fileMatch[2] ?? '',
+ lines: [],
+ }
+ files.push(current)
+ continue
+ }
+
+ if (!current) continue
+
+ const moveMatch = line.match(/^\*\*\* Move to: (.+)$/)
+ if (moveMatch) {
+ current.movedTo = moveMatch[1] ?? ''
+ continue
+ }
+
+ if (line === '*** Begin Patch' || line === '*** End of File' || line.startsWith('@@')) continue
+ if (line.startsWith('+')) current.lines.push({ kind: '+', text: line.slice(1) })
+ else if (line.startsWith('-')) current.lines.push({ kind: '-', text: line.slice(1) })
+ else if (line.startsWith(' ')) current.lines.push({ kind: 'ctx', text: line.slice(1) })
+ }
+
+ return {
+ files,
+ totalFiles: files.length,
+ fileCountTruncated: page.hasNext && endMatch === null,
+ patchClosed: endMatch !== null,
+ previewTruncated: page.hasNext && endMatch === null,
+ }
+}
+
+function firstLine(result: ToolResultBlock | null | undefined): string | undefined {
+ const content = result?.content
+ const text = typeof content === 'string'
+ ? content
+ : Array.isArray(content)
+ ? String((content[0] as { text?: unknown })?.text ?? '')
+ : ''
+ return text ? text.split('\n')[0].slice(0, 200) : 'patch failed'
+}
diff --git a/src/renderer/src/session-runtime/semantic/foldEvent.test.ts b/src/renderer/src/session-runtime/semantic/foldEvent.test.ts
new file mode 100644
index 00000000..f1f49a17
--- /dev/null
+++ b/src/renderer/src/session-runtime/semantic/foldEvent.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { emptySemanticRuntime } from '@renderer/session-runtime/state'
+
+import { foldSemanticEvent } from './foldEvent'
+
+describe('foldSemanticEvent', () => {
+ it('preserves finalized object input for OpenCode semantic tools', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-07-19T00:00:00.000Z'))
+
+ let state = emptySemanticRuntime()
+ state = foldSemanticEvent(state, {
+ type: 'block_started',
+ turnId: 'turn-oc-1',
+ blockIndex: 0,
+ kind: 'tool_use',
+ toolName: 'read',
+ toolUseId: 'oc-tool-1',
+ source: 'opencode-sse',
+ ts: 1,
+ }, 'opencode')
+ state = foldSemanticEvent(state, {
+ type: 'tool_input_finalized',
+ turnId: 'turn-oc-1',
+ blockIndex: 0,
+ toolName: 'read',
+ toolUseId: 'oc-tool-1',
+ input: { filePath: '/repo/src/example.ts', offset: 1, limit: 3 },
+ source: 'opencode-sse',
+ ts: 2,
+ }, 'opencode')
+
+ expect(state.currentTurn?.blocks[0]?.parsedInput).toEqual({
+ filePath: '/repo/src/example.ts',
+ offset: 1,
+ limit: 3,
+ })
+ expect(state.currentTurn?.blocks[0]?.inputJson).toBe(
+ '{"filePath":"/repo/src/example.ts","offset":1,"limit":3}',
+ )
+
+ vi.useRealTimers()
+ })
+})
diff --git a/src/renderer/src/session-runtime/semantic/foldEvent.ts b/src/renderer/src/session-runtime/semantic/foldEvent.ts
index e76bafc5..40b55f3b 100644
--- a/src/renderer/src/session-runtime/semantic/foldEvent.ts
+++ b/src/renderer/src/session-runtime/semantic/foldEvent.ts
@@ -62,6 +62,20 @@ function eventTargetsDifferentTurn(
return typeof ev.turnId === 'string' && ev.turnId !== currentTurn.turnId
}
+function stringifySemanticInput(value: unknown): string | undefined {
+ // WHY stringify the provider object at the reducer boundary: OpenCode's SSE
+ // `tool_input_finalized` carries a parsed object (`input`) instead of the raw
+ // JSON string Claude/Codex expose. The rest of the runtime already treats
+ // `inputJson` as the generic disclosure/debug slot, so serializing once here
+ // keeps every downstream consumer provider-neutral instead of forcing each
+ // renderer branch to invent its own OpenCode-only raw-input rule.
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return undefined
+ }
+}
+
// ---------------------------------------------------------------------------
// Per-provider fold policy (2026-07-06 opencode rendering fix)
// ---------------------------------------------------------------------------
@@ -643,15 +657,23 @@ export function foldSemanticEvent(
if (idx === null) break
const block = currentTurn.blocks[idx]
if (!block) break
+ const finalizedInput = asRecord(ev.parsed) ?? asRecord(ev.input) ?? block.parsedInput
+ const finalizedInputJson =
+ typeof ev.inputJson === 'string'
+ ? ev.inputJson
+ : ev.input !== undefined
+ ? stringifySemanticInput(ev.input) ?? block.inputJson
+ : block.inputJson
currentTurn = {
...currentTurn,
blocks: {
...currentTurn.blocks,
[idx]: {
...block,
- inputJson: typeof ev.inputJson === 'string' ? ev.inputJson : block.inputJson,
- inputJsonValid: Boolean(ev.parsed),
- parsedInput: asRecord(ev.parsed) ?? block.parsedInput,
+ inputJson: finalizedInputJson,
+ inputJsonValid:
+ finalizedInput === block.parsedInput ? block.inputJsonValid : Boolean(finalizedInput),
+ parsedInput: finalizedInput,
parseError:
typeof ev.parseError === 'string' ? ev.parseError : block.parseError,
finalized: true,
diff --git a/testing/fixtures/rendering-shapes/opencode/apply-patch/final.json b/testing/fixtures/rendering-shapes/opencode/apply-patch/final.json
new file mode 100644
index 00000000..78b359cf
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/apply-patch/final.json
@@ -0,0 +1,30 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode committed tool-use soak. The direct patchText envelope now renders through the shared code-edit protocol when a real file header closes; malformed patch bodies still fall back generically.",
+ "cases": [
+ {
+ "expectedRoute": "specialized",
+ "expectedReceipt": {
+ "rendererId": "opencode.rows.dispatch"
+ },
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-apply-patch-fixture-1",
+ "name": "apply_patch",
+ "input": {
+ "patchText": "*** Begin Patch\n*** Update File: src/example.ts\n@@\n-old\n+new\n*** End Patch"
+ }
+ }
+ },
+ {
+ "expectedRoute": "generic",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-apply-patch-fixture-2",
+ "name": "apply_patch",
+ "input": {
+ "patchText": "*** Begin Patch\nthis patch never reaches a file header"
+ }
+ }
+ }
+ ]
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/apply-patch/semantic-final.json b/testing/fixtures/rendering-shapes/opencode/apply-patch/semantic-final.json
new file mode 100644
index 00000000..8f1b7a58
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/apply-patch/semantic-final.json
@@ -0,0 +1,38 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape. The structural keys are copied from fp2-9003dc12; one case proves the owned code-edit path and one proves the generic decline for malformed bodies.",
+ "cases": [
+ {
+ "expectedRoute": "specialized",
+ "expectedReceipt": {
+ "rendererId": "opencode.rows.dispatch"
+ },
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "apply_patch",
+ "toolUseId": "patch-live-owned",
+ "inputJson": "{\"patchText\":\"*** Begin Patch\\n*** Update File: src/example.ts\\n@@\\n-old\\n+new\\n*** End Patch\"}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "patchText": "*** Begin Patch\n*** Update File: src/example.ts\n@@\n-old\n+new\n*** End Patch"
+ },
+ "finalized": true
+ }
+ },
+ {
+ "expectedRoute": "generic",
+ "semanticBlock": {
+ "blockIndex": 1,
+ "kind": "tool_use",
+ "toolName": "apply_patch",
+ "toolUseId": "patch-live-generic",
+ "inputJson": "{\"patchText\":\"*** Begin Patch\\nnot enough structure yet\"}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "patchText": "*** Begin Patch\nnot enough structure yet"
+ },
+ "finalized": true
+ }
+ }
+ ]
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/bash/committed.json b/testing/fixtures/rendering-shapes/opencode/bash/committed.json
new file mode 100644
index 00000000..ff040936
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/bash/committed.json
@@ -0,0 +1,60 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode recording soak. Both cases preserve the observed committed invocation grammar (fp2-f0f0a673) while pinning the finite route split between generic shell commands and the shared Git command protocol.",
+ "sourceShapeEvidence": {
+ "capturedAt": "2026-07-19T11:39:04.242Z",
+ "provider": "opencode",
+ "sessionId": "295304f9-82ca-4945-bbc5-30dafe959281",
+ "invocation": {
+ "fingerprint": "fp2-f0f0a673",
+ "shapePaths": [
+ ":object",
+ "id:string",
+ "input.command:string",
+ "input.description:string",
+ "input.timeout:number",
+ "input.workdir:string",
+ "input:object",
+ "name:string",
+ "type:string"
+ ],
+ "discriminatorValues": {
+ "name": "bash",
+ "type": "tool_use"
+ }
+ }
+ },
+ "cases": [
+ {
+ "expectedRoute": "generic",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-bash-generic",
+ "name": "bash",
+ "input": {
+ "command": "npm run typecheck",
+ "description": "Typechecks the project",
+ "timeout": 120000,
+ "workdir": "/workspace/project"
+ }
+ }
+ },
+ {
+ "expectedRoute": "specialized",
+ "expectedReceipt": {
+ "rendererId": "shared.command",
+ "protocolId": "command.git"
+ },
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-bash-git",
+ "name": "bash",
+ "input": {
+ "command": "git status --short",
+ "description": "Shows repository changes",
+ "timeout": 120000,
+ "workdir": "/workspace/project"
+ }
+ }
+ }
+ ]
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/bash/semantic-final.json b/testing/fixtures/rendering-shapes/opencode/bash/semantic-final.json
new file mode 100644
index 00000000..f992f1b7
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/bash/semantic-final.json
@@ -0,0 +1,45 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape. The structural keys are copied from fp2-41fff07e; one case proves the shared Git command route and one keeps an ordinary shell command generic.",
+ "cases": [
+ {
+ "expectedRoute": "specialized",
+ "expectedReceipt": {
+ "rendererId": "shared.command",
+ "protocolId": "command.git"
+ },
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "bash",
+ "toolUseId": "bash-live-git",
+ "inputJson": "{\"command\":\"git status --short\",\"description\":\"Shows repository changes\",\"timeout\":120000,\"workdir\":\"/workspace/project\"}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "command": "git status --short",
+ "description": "Shows repository changes",
+ "timeout": 120000,
+ "workdir": "/workspace/project"
+ },
+ "finalized": true
+ }
+ },
+ {
+ "expectedRoute": "generic",
+ "semanticBlock": {
+ "blockIndex": 1,
+ "kind": "tool_use",
+ "toolName": "bash",
+ "toolUseId": "bash-live-generic",
+ "inputJson": "{\"command\":\"npm run typecheck\",\"description\":\"Typechecks the project\",\"timeout\":120000,\"workdir\":\"/workspace/project\"}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "command": "npm run typecheck",
+ "description": "Typechecks the project",
+ "timeout": 120000,
+ "workdir": "/workspace/project"
+ },
+ "finalized": true
+ }
+ }
+ ]
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/glob/final.json b/testing/fixtures/rendering-shapes/opencode/glob/final.json
new file mode 100644
index 00000000..b24e8061
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/glob/final.json
@@ -0,0 +1,12 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode committed tool-use soak; values are synthetic while the admitted invocation structure is preserved.",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-glob-fixture-1",
+ "name": "glob",
+ "input": {
+ "path": "/workspace/project",
+ "pattern": "src/**/*.ts"
+ }
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/glob/semantic-final.json b/testing/fixtures/rendering-shapes/opencode/glob/semantic-final.json
new file mode 100644
index 00000000..c1445b59
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/glob/semantic-final.json
@@ -0,0 +1,16 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape; values are reduced while preserving fp2-f879ddcb.",
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "glob",
+ "toolUseId": "glob-live",
+ "inputJson": "{\"pattern\":\"src/**/*.ts\",\"path\":\"/workspace/project\"}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "pattern": "src/**/*.ts",
+ "path": "/workspace/project"
+ },
+ "finalized": true
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/grep/final.json b/testing/fixtures/rendering-shapes/opencode/grep/final.json
new file mode 100644
index 00000000..01bedf78
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/grep/final.json
@@ -0,0 +1,13 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode committed tool-use soak; values are synthetic while the admitted invocation structure is preserved.",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-grep-fixture-1",
+ "name": "grep",
+ "input": {
+ "pattern": "renderSemanticBlock",
+ "path": "/workspace/project/src",
+ "include": "*.ts"
+ }
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/grep/semantic-final.json b/testing/fixtures/rendering-shapes/opencode/grep/semantic-final.json
new file mode 100644
index 00000000..c29e07b9
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/grep/semantic-final.json
@@ -0,0 +1,17 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape; values are reduced while preserving fp2-6507bf37.",
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "grep",
+ "toolUseId": "grep-live",
+ "inputJson": "{\"pattern\":\"renderSemanticBlock\",\"path\":\"/workspace/project/src\",\"include\":\"*.ts\"}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "pattern": "renderSemanticBlock",
+ "path": "/workspace/project/src",
+ "include": "*.ts"
+ },
+ "finalized": true
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/read/final.json b/testing/fixtures/rendering-shapes/opencode/read/final.json
new file mode 100644
index 00000000..3f179acd
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/read/final.json
@@ -0,0 +1,19 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode committed read soak. The request stays generic, while the paired result proves the provider-owned read result renderer route.",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-read-fixture-1",
+ "name": "read",
+ "input": {
+ "filePath": "/workspace/project/src/example.ts",
+ "offset": 41,
+ "limit": 2
+ }
+ },
+ "toolResult": {
+ "type": "tool_result",
+ "tool_use_id": "opencode-read-fixture-1",
+ "content": "/workspace/project/src/example.ts\nfile\n\n41: export const answer = 42\n42: export type Answer = number\n",
+ "is_error": false
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/read/semantic-final.json b/testing/fixtures/rendering-shapes/opencode/read/semantic-final.json
new file mode 100644
index 00000000..75deea8d
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/read/semantic-final.json
@@ -0,0 +1,44 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape. The family has both a plain finalized invocation and a provider-owned finalized row once the tagged read result is attached.",
+ "cases": [
+ {
+ "expectedRoute": "generic",
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "read",
+ "toolUseId": "read-live-generic",
+ "inputJson": "{\"filePath\":\"/workspace/project/src/example.ts\",\"offset\":1,\"limit\":2}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "filePath": "/workspace/project/src/example.ts",
+ "offset": 1,
+ "limit": 2
+ },
+ "finalized": true
+ }
+ },
+ {
+ "expectedRoute": "specialized",
+ "expectedReceipt": {
+ "rendererId": "opencode.rows.dispatch"
+ },
+ "semanticBlock": {
+ "blockIndex": 1,
+ "kind": "tool_use",
+ "toolName": "read",
+ "toolUseId": "read-live-owned",
+ "inputJson": "{\"filePath\":\"/workspace/project/src/example.ts\",\"offset\":1,\"limit\":2}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "filePath": "/workspace/project/src/example.ts",
+ "offset": 1,
+ "limit": 2
+ },
+ "finalized": true,
+ "resultContent": "/workspace/project/src/example.ts\nfile\n\n1: export const answer = 42\n2: export type Answer = number\n",
+ "resultAt": 1
+ }
+ }
+ ]
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/read/semantic-prefix.json b/testing/fixtures/rendering-shapes/opencode/read/semantic-prefix.json
new file mode 100644
index 00000000..cd136511
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/read/semantic-prefix.json
@@ -0,0 +1,15 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape; values are reduced while preserving fp2-1549305d.",
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "read",
+ "toolUseId": "read-live-prefix",
+ "inputJson": "{\"filePath\":\"/workspace/project/src/example.ts\",\"offset\":1,\"limit\":2}",
+ "parsedInput": {
+ "filePath": "/workspace/project/src/example.ts",
+ "offset": 1,
+ "limit": 2
+ }
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/skill/final.json b/testing/fixtures/rendering-shapes/opencode/skill/final.json
new file mode 100644
index 00000000..7e6566c6
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/skill/final.json
@@ -0,0 +1,11 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode committed tool-use soak; the current observed grammar is only a named skill lookup, so the shared structured row is the honest contract.",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-skill-fixture-1",
+ "name": "skill",
+ "input": {
+ "name": "writing-plans"
+ }
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/skill/semantic-final.json b/testing/fixtures/rendering-shapes/opencode/skill/semantic-final.json
new file mode 100644
index 00000000..88203fba
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/skill/semantic-final.json
@@ -0,0 +1,15 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape; values are reduced while preserving fp2-66bcff9c.",
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "skill",
+ "toolUseId": "skill-live",
+ "inputJson": "{\"name\":\"writing-plans\"}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "name": "writing-plans"
+ },
+ "finalized": true
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/todowrite/final.json b/testing/fixtures/rendering-shapes/opencode/todowrite/final.json
new file mode 100644
index 00000000..88a0dbdd
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/todowrite/final.json
@@ -0,0 +1,22 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode committed todowrite soak; values are synthetic while the checklist invocation grammar is preserved.",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-todo-fixture-1",
+ "name": "todowrite",
+ "input": {
+ "todos": [
+ {
+ "content": "Catalog the first OpenCode shapes",
+ "status": "completed",
+ "priority": "high"
+ },
+ {
+ "content": "Add curated fixtures",
+ "status": "in_progress",
+ "priority": "high"
+ }
+ ]
+ }
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/todowrite/semantic-final.json b/testing/fixtures/rendering-shapes/opencode/todowrite/semantic-final.json
new file mode 100644
index 00000000..1376da91
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/todowrite/semantic-final.json
@@ -0,0 +1,21 @@
+{
+ "evidence": "Synthetic from the 2026-07-19 OpenCode SSE/reducer shape; values are reduced while preserving fp2-dc7d1b61.",
+ "semanticBlock": {
+ "blockIndex": 0,
+ "kind": "tool_use",
+ "toolName": "todowrite",
+ "toolUseId": "todo-live",
+ "inputJson": "{\"todos\":[{\"content\":\"Verify renderer\",\"status\":\"in_progress\",\"priority\":\"high\"}]}",
+ "inputJsonValid": true,
+ "parsedInput": {
+ "todos": [
+ {
+ "content": "Verify renderer",
+ "status": "in_progress",
+ "priority": "high"
+ }
+ ]
+ },
+ "finalized": true
+ }
+}
diff --git a/testing/fixtures/rendering-shapes/opencode/tool-result/final.json b/testing/fixtures/rendering-shapes/opencode/tool-result/final.json
new file mode 100644
index 00000000..34cfcfaa
--- /dev/null
+++ b/testing/fixtures/rendering-shapes/opencode/tool-result/final.json
@@ -0,0 +1,63 @@
+{
+ "evidence": "Curated from the 2026-07-19 OpenCode committed result soak. The same base tool_result envelope can remain generic or graduate to a provider-owned row depending on the paired invocation.",
+ "sourceShapeEvidence": {
+ "capturedAt": "2026-07-19T11:39:04.242Z",
+ "provider": "opencode",
+ "sessionId": "295304f9-82ca-4945-bbc5-30dafe959281",
+ "result": {
+ "fingerprint": "fp2-f31bc9d2",
+ "shapePaths": [
+ ":object",
+ "content:string",
+ "is_error:boolean",
+ "tool_use_id:string",
+ "type:string"
+ ],
+ "discriminatorValues": {
+ "type": "tool_result"
+ }
+ }
+ },
+ "cases": [
+ {
+ "expectedRoute": "generic",
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-result-generic-use",
+ "name": "glob",
+ "input": {
+ "path": "/workspace/project",
+ "pattern": "src/**/*.ts"
+ }
+ },
+ "toolResult": {
+ "type": "tool_result",
+ "tool_use_id": "opencode-result-generic-use",
+ "content": "No files found",
+ "is_error": false
+ }
+ },
+ {
+ "expectedRoute": "specialized",
+ "expectedReceipt": {
+ "rendererId": "opencode.rows.dispatch"
+ },
+ "toolUse": {
+ "type": "tool_use",
+ "id": "opencode-result-read-use",
+ "name": "read",
+ "input": {
+ "filePath": "/workspace/project/src/example.ts",
+ "offset": 1,
+ "limit": 3
+ }
+ },
+ "toolResult": {
+ "type": "tool_result",
+ "tool_use_id": "opencode-result-read-use",
+ "content": "/workspace/project/src/example.ts\nfile\n\n1: export const answer = 42\n2: export type Answer = number\n3: export function getAnswer(): Answer {\n",
+ "is_error": false
+ }
+ }
+ ]
+}