fix(opencode): render edit/write tool calls through the shared diff views - #1644
fix(opencode): render edit/write tool calls through the shared diff views#1644junmo-kim wants to merge 11 commits into
Conversation
There was a problem hiding this comment.
Findings
-
[Major] Preserve OpenCode's
replaceAllargument — OpenCode v1.18.x edit input includes optionalreplaceAll, but the new canonicalizer rebuilds the object with only path/old/new. The persisted tool call therefore loses whether the operation replaced one occurrence or all occurrences. Evidencecli/src/agent/utils.ts:38.Suggested fix:
const replaceAll = isObject(rawInput) && typeof rawInput.replaceAll === 'boolean' ? rawInput.replaceAll : undefined; return { name: 'Edit', input: { file_path: filePath, old_string: oldString, new_string: newString, ...(replaceAll === undefined ? {} : { replace_all: replaceAll }) } };
-
[Minor] Keep id-less hook pairing keys stable across canonicalization — canonicalizing
edittoEditbefore computing queue signatures makes an empty/partialbeforeuse a different fallback key from the fullafter. That leaves stale queue entries; with usable partial input it also emits a second tool call and leaves the first without a result. Evidencecli/src/opencode/opencodeLocalLauncher.ts:382.Suggested fix:
let toolInput: unknown = parseMaybeJson(...); const signature = buildToolSignature(name, toolInput); const fallbackSignature = buildToolSignature(name, null); const canonical = canonicalizeDiffToolInput(toolInput); if (canonical) { name = canonical.name; toolInput = canonical.input; }
Summary
Review mode: initial
Two issues found: one Major input-data-loss regression and one Minor execute-hook pairing regression.
Testing
Not run locally (review-only; PR content was not executed). GitHub integration check passed; test was pending when inspected.
HAPI Bot
- canonicalizeDiffToolInput now carries OpenCode's optional replaceAll through to the canonical Edit input as replace_all, so persisted history keeps whether an edit replaced one occurrence or all. - opencodeLocalLauncher computes the execute-hook pairing signature from the raw name/input BEFORE canonicalizing, so an id-less 'before' with empty or partial args shares a queue key with the full 'after'.
There was a problem hiding this comment.
Findings
-
[Major] Preserve snake_case
replace_allduring canonicalization — the helper explicitly accepts canonical snake_case edit inputs, but reads only camelCasereplaceAllbefore rebuilding the object. An input such as{ file_path, old_string, new_string, replace_all: true }therefore loses the execution argument when it passes through any of the new ACP/local call sites. Evidencecli/src/agent/utils.ts:38.Suggested fix:
let replaceAll: boolean | undefined; if (isObject(rawInput)) { if (typeof rawInput.replaceAll === "boolean") { replaceAll = rawInput.replaceAll; } else if (typeof rawInput.replace_all === "boolean") { replaceAll = rawInput.replace_all; } }
Summary
Review mode: follow-up after new commits
One Major data-loss case remains in the canonicalizer. The prior id-less hook-pairing issue is no longer present in the latest diff.
Testing
Not run (automation; PR code was not executed). GitHub integration check passed; the main test job was still in progress when reviewed. Add helper coverage for snake_case replace_all with both true and false.
HAPI Bot
canonicalizeDiffToolInput accepts already-canonical snake_case edit inputs (file_path/old_string/new_string), but only read camelCase replaceAll. Read replace_all too so the execution argument survives every call site.
There was a problem hiding this comment.
Findings
-
[Major] Upgrade partial local edit/write calls when the full args arrive —
canonicalizeDiffToolInput()only changes the current hook snapshot. Iftool.execute.beforehas a non-empty partial input such as{ filePath }, line 387 returnsnull, the call is emitted and recorded insentToolCalls; the fullaftersnapshot is canonicalized but line 443 suppresses the replacement call. The new regression case atcli/src/opencode/utils/opencodeLocalToolParse.test.ts:317therefore still emitsname: 'edit'with only{ filePath }, so the shared diff view never receives the full input. The same first-usable-input dedupe affectsmessage.part.updatedatcli/src/opencode/opencodeLocalLauncher.ts:331. Evidencecli/src/opencode/opencodeLocalLauncher.ts:387.Suggested fix:
const emittedToolInputs = new Map<string, unknown>(); const previousInput = emittedToolInputs.get(callId); const shouldEmitToolCall = previousInput === undefined || (canonicalizeDiffToolInput(previousInput) === null && canonicalizeDiffToolInput(toolInput) !== null); if (shouldEmitToolCall) { emittedToolInputs.set(callId, toolInput); session.sendAgentMessage({ type: 'tool-call', name, callId, input: toolInput }); }
Apply the same upgrade rule to both local event paths, and assert that the partial-before test's first message is
Editwithfile_path,old_string, andnew_string.
Summary
Review mode: follow-up after new commits
One Major issue remains in the local-mode lifecycle. The prior snake_case replace_all finding is resolved by the latest commit.
Testing
Not run (automation; PR code was not executed). Integration passed; the main test job was pending when reviewed. Add partial-to-full lifecycle coverage for both tool.execute.* and message.part.updated.
HAPI Bot
…arrive
A partial native before ({filePath}) was emitted as-is and recorded as sent,
so the later canonical full input was deduped and the web Edit/Write view never
received the complete arguments. Track the emitted input per callId and, when
a partial native edit/write call is superseded by a canonical full input, emit
a replacement tool-call (same callId) before the result. Applies to both the
message.part.updated and tool.execute.* local event paths.
There was a problem hiding this comment.
Findings
-
[Minor] Release completed tool inputs from upgrade tracking —
emittedToolInputsretains every emitted Edit/Write input for the lifetime of the local launcher. Those values include full Writecontentand Edit old/new strings, so long-running sessions accumulate file payloads even thoughsentToolResultsprevents any later upgrade after completion. Evidencecli/src/opencode/opencodeLocalLauncher.ts:245.Suggested fix:
sentToolResults.add(toolResult.callId); emittedToolInputs.delete(toolResult.callId); // tool.execute.after path sentToolResults.add(callId); emittedToolInputs.delete(callId);
Summary
Review mode: follow-up after new commits
One Minor resource-retention issue remains. The prior partial-to-full replacement issue is no longer present in the latest diff.
Testing
Not run (automation; PR code was not executed). Integration passed; the main test job was pending when reviewed. Add lifecycle coverage that verifies completed calls are removed from upgrade tracking.
HAPI Bot
emittedToolInputs retained the full emitted Write content and Edit old/new strings for the whole launcher lifetime. Once a call is completed (sentToolResults), later upgrades are already blocked, so drop the stored payload in both result paths. Add a lifecycle test that a stale part for a completed callId neither re-emits nor emits a duplicate result.
There was a problem hiding this comment.
Findings
-
[Minor] Gate diff canonicalization on edit/write semantics — the helper intentionally classifies only from argument keys, while ACP and local callers invoke it without the original tool identity. ACP/custom tool schemas are arbitrary, so any non-edit tool accepting
{filePath, content}is renamed toWrite; extra arguments are also discarded from the stored presentation. Evidencecli/src/agent/utils.ts:24,cli/src/agent/backends/acp/AcpMessageHandler.ts:753,cli/src/opencode/utils/opencodeLocalToolParse.ts:77.Suggested fix:
export function canonicalizeDiffToolInput( rawInput: unknown, semanticHint: string | null ): CanonicalDiffToolInput | null { const kind = semanticHint?.trim().toLowerCase(); if (!kind || !['edit', 'write', 'write_file', 'replace', 'modify', 'file_edit'].includes(kind)) { return null; } // Existing shape mapping. } // ACP: canonicalizeDiffToolInput(input, asString(update.kind) ?? name) // Local: canonicalizeDiffToolInput(input, name)
-
[Nit] Restore function-local indentation — the new tracking declarations are flush-left inside
opencodeLocalLauncher, contrary to the repository's 4-space indentation rule. Evidencecli/src/opencode/opencodeLocalLauncher.ts:239.Suggested fix:
const sentTextParts = new Set<string>(); const sentToolCalls = new Set<string>(); const sentToolResults = new Set<string>(); // Emitted tool input per callId... const emittedToolInputs = new Map<string, unknown>();
Summary
Review mode: follow-up after new commits
One Minor correctness-scope issue and one Nit formatting issue remain. The prior completed-input retention issue is addressed by the new deletion paths.
Testing
Not run (automation; security constraint prohibits executing PR code). Integration CI passed; the main test job was still running when reviewed. Add a regression case proving a non-edit custom tool with { filePath, content } retains its original name/input. The new cleanup test only checks emitted messages and would still pass without the map deletion because sentToolResults suppresses the later event; extracting the tracker policy would allow a direct state assertion.
HAPI Bot
…ng decls The per-session tracking declarations (sentTextParts, sentToolCalls, sentToolResults, emittedToolInputs) were flush-left inside opencodeLocalLauncher, violating the repo's 4-space indentation rule.
There was a problem hiding this comment.
Findings
-
[Minor] Gate diff canonicalization on edit/write semantics —
canonicalizeDiffToolInputreceives no tool identity, while all ACP and OpenCode-local tool inputs pass through it. ACP sessions accept arbitrary MCP tools, so an unrelated tool with{ filePath, content, mode }is renamed toWriteandmodeis discarded from the persisted presentation. Evidencecli/src/agent/utils.ts:31,cli/src/agent/backends/acp/AcpMessageHandler.ts:753,cli/src/opencode/utils/opencodeLocalToolParse.ts:77.Suggested fix:
export function canonicalizeDiffToolInput( rawInput: unknown, semanticHint: string | null ): CanonicalDiffToolInput | null { const kind = semanticHint?.trim().toLowerCase(); if (!kind || !["edit", "write", "write_file", "replace", "modify", "file_edit"].includes(kind)) { return null; } // Existing shape mapping. } // ACP: pass update.kind, then the stored/original tool name as fallback. // OpenCode local: pass the native tool name.
Summary
Review mode: follow-up after new commits
One Minor correctness-scope issue remains. The follow-up commit fixes the prior indentation nit but does not constrain shape-based canonicalization. No other high-confidence issues found in the current full diff.
Testing
Not run (automation; security constraint prohibits executing PR code). Test and integration checks were in progress when reviewed. Add a regression case proving a non-edit tool with { filePath, content, mode } retains its original name and complete input.
HAPI Bot
canonicalizeDiffToolInput now takes the tool's semantic name/kind and only
normalizes known edit/write aliases (edit, write, write_file, replace, modify,
file_edit). The Edit shape (path + both old/new) is unambiguous on its own, but
Write is just {path, content} — a shape many non-edit MCP tools also accept —
so without the gate those tools were renamed to Write and their extra args
dropped. OpenCode maps both edit and write to kind 'edit', which stays in the
allow-list, so the native-shape cases keep working. ACP callers pass the update
kind (falling back to the derived name); local callers pass the tool name.
There was a problem hiding this comment.
Findings
-
[Minor] Re-emit changed canonical inputs, not only the first canonical transition — the new
shouldEmitcondition only handlesnull -> canonical. BecausecanonicalizeDiffToolInputaccepts empty and partial string values, a runningWritesuch as{ content: "" }is already canonical; a later update containing the final content is suppressed, leaving the web diff with stale input. The same condition is repeated for execute-hook events. Evidencecli/src/opencode/opencodeLocalLauncher.ts:343,cli/src/opencode/opencodeLocalLauncher.ts:451,cli/src/opencode/opencodeLocalLauncher.ts:473.Suggested fix:
const canonicalChanged = currentCanonical !== null && hashObject(currentCanonical) !== hashObject(previousCanonical); const shouldEmit = previousInput === undefined || canonicalChanged;
Summary
Review mode: follow-up after new commits
One Minor local-streaming correctness issue remains. The prior semantic-gating finding is addressed; no other high-confidence issues found in the current full diff.
Testing
Not run (automation; security constraint prohibits executing PR code). GitHub integration passed; the main test workflow was pending when reviewed. Add regression cases for shape-complete Write/Edit inputs changing from empty or partial strings to final strings through both message.part.updated and execute-hook paths.
HAPI Bot
| const currentCanonical = canonicalizeDiffToolInput(toolCall.input, toolCall.name); | ||
| // Emit on first usable input, and replace a partial native | ||
| // edit/write input with the full canonical one when it arrives. | ||
| const shouldEmit = previousInput === undefined |
There was a problem hiding this comment.
[MINOR] Re-emit when canonical content changes
This only upgrades null -> canonical. Since the canonicalizer accepts empty/partial strings, a running Write with { content: "" } is already canonical; a later update with the final content is suppressed, so the web retains the stale diff. The same predicate is repeated at lines 451 and 473.
Suggested fix:
const canonicalChanged = currentCanonical !== null
&& hashObject(currentCanonical) !== hashObject(previousCanonical);
const shouldEmit = previousInput === undefined || canonicalChanged;Add a regression where a shape-complete Write/Edit input changes before completion.
shouldEmit only handled the null->canonical transition, so a shape-complete
Write/Edit that already canonicalizes (e.g. {content: ""}) was suppressed
when the final content arrived, leaving the web diff with stale input. Emit on
first usable input and re-emit whenever the canonical input value changes,
across both message.part.updated and tool.execute.* paths.
There was a problem hiding this comment.
Findings
None.
Summary
Review mode: follow-up after new commits
No Blocker/Major/Minor/Nit issues found in the latest full diff. The prior stale-input finding is addressed.
Residual testing gap: the changed-input behavior for message.part.updated in cli/src/opencode/opencodeLocalLauncher.ts:335 lacks a dedicated lifecycle regression; the new regression covers only the execute-hook path.
Testing
Not run (automation; security rule prohibits executing PR code). GitHub integration passed; the main test workflow was pending at review time. Suggested test: running Write with empty content to completed Write with final content via message.part.updated.
HAPI Bot
…ed path The execute-hook path already had a regression for canonical inputs changing from empty to final content; add the matching case for message.part.updated so both local emit paths are covered.
There was a problem hiding this comment.
Findings
None.
Summary
Review mode: follow-up after new commits
No Blocker/Major/Minor/Nit issues found in the latest full diff. The follow-up commit adds coverage for the previously noted changed-input message.part.updated lifecycle.
Residual testing risk: cli/src/opencode/utils/opencodeLocalToolParse.test.ts:194 exercises a test-only collectMessages mirror rather than opencodeLocalLauncher itself, so production/test emit-policy divergence would not be detected.
Testing
Not run (automation; security rule prohibits executing PR code). GitHub integration passed; the main test workflow was pending at review time. Suggested follow-up: extract the message-part emit decision into a shared helper used by the launcher and test that helper directly.
HAPI Bot
Symptom
OpenCode
edit/writetool calls render as a generic card with raw JSON input instead of the shared diff views (backlog #51):Root causes
Three independent gaps, all on the CLI adapter side:
AcpMessageHandlerderives the tool name from the ACPtitle, so OpenCode's edit arrives asedit; the web's tool registry only knowsEdit/Write, so it falls back to the generic raw-JSON presentation.{filePath, oldString, newString}/{filePath, content}); the webEditView/WriteViewonly read Claude-shaped keys (file_path,old_string,new_string).hoistDiffContentIntoInputrequires a leading diff block +_meta.kind; OpenCode's completed content is a text block first and carries no_meta.kind.Wire shapes verified against opencode v1.18.x source (
acp/tool.ts) and live captures.Fix
Canonicalize at the CLI adapter boundary (no web changes), following the existing Gemini precedent:
canonicalizeDiffToolInput()(cli/src/agent/utils.ts) — shape-based: path + old/new →Edit {file_path, old_string, new_string}; path + content →Write {file_path, content}; everything else returns null so existing fallbacks are untouched.AcpMessageHandlerinitial call / running update / enrichment fallback), the local-mode part parser (opencodeLocalToolParse), and before execute-hook signature computation (opencodeLocalLauncher) so before/after pairing stays consistent.Out of scope:
apply_patch/patch-text shapes, generic object-input presentation (backlog #38). No migration for messages already persisted in the raw shape.Verification
upstream/main(1f5602ad, release 0.29.0): cli 2431 passed (+1 skipped), hub 1196, web 2796, shared 283, relay 80; typecheck clean.HAPI_HOME+ dynamic ports, real hub + vite + headless Chromium) seeded with the exact pre/post wire shapes — before shows the raw JSON card, after shows the unified diff and the Write content view: