Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/providers/opencode/renderer/adapters/codeEdit.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
20 changes: 20 additions & 0 deletions src/providers/opencode/renderer/adapters/codeEdit.ts
Original file line number Diff line number Diff line change
@@ -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)
}
39 changes: 39 additions & 0 deletions src/providers/opencode/renderer/components/apply-patch/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-1">
<CodeEditView model={model} />
{previewIncomplete ? (
<details className="text-[11px] text-muted">
<summary className="cursor-pointer select-none">
Rich preview is partial · view exact paged patch
</summary>
<div className="mt-1 rounded border border-border bg-surface px-2 py-1.5">
<PagedTextViewer source={rawPatch} />
</div>
</details>
) : null}
</div>
)
})
6 changes: 6 additions & 0 deletions src/providers/opencode/renderer/rows/dispatch.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 <OpencodeApplyPatchRow model={applyPatch} rawPatch={rawOpencodeApplyPatchText(block)} />
}
const todo = fromOpencodeTodoUse(block)
return todo ? <OpencodeTodoRow model={todo} /> : undefined
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, ToolResultBlock>(),
}

function block(overrides: Partial<SemanticLiveBlock>): 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: '<path>/workspace/project/src/example.ts</path>\n<type>file</type>\n<content>\n1: export const answer = 42\n2: export type Answer = number\n</content>',
}), 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' },
}))
})
})
98 changes: 98 additions & 0 deletions src/providers/opencode/renderer/semantic/dispatch.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ToolResultBlock>
},
): 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 (`<path>…<content>` 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: (
<div className="flex flex-col gap-2">
<JsonToolRow block={tool} live />
{ownedResult}
</div>
),
}
}
}

const applyPatch = fromOpencodeApplyPatch(tool, {
streaming: block.finalized !== true,
result,
})
if (applyPatch) {
return {
action: 'render',
receipt: { rendererId: 'opencode.rows.dispatch' },
node: <OpencodeApplyPatchRow model={applyPatch} rawPatch={rawOpencodeApplyPatchText(tool)} />,
}
}

return renderLiveProviderTool({
tool,
finalized: block.finalized === true,
resultPresent: result !== null,
resultContent: block.resultContent ?? '',
resultIsError: block.resultIsError === true,
committedResults: context.committedToolResults,
renderOperation: renderOpencodeOperation,
})
}
Loading
Loading