Skip to content

ACP edit permission requests omit the diff content block for edits inside indented blocks #37266

Description

@skavans

Bug

When opencode requests permission for an edit, the toolCall.content block (type: "diff") is silently dropped whenever the edited hunk sits entirely inside an indented block. ACP clients (Zed, VS Code extensions, JetBrains, CodeCompanion, etc.) then have no diff to show next to the approval prompt.

The actual edit is not affected — it still applies correctly via the fuzzy replacers in edit.ts. This is a preview-only regression.

Root cause

edit.ts trims common indentation from the unified diff (trimDiff) for readability of the in-TUI diff. Then permission.ts::diffContentForPatch tries to reconstruct the result by re-applying that (now indentation-stripped) patch to the file:

const next = applyPatch(content, diff)   // fails for indented hunks
if (next === false) return undefined     // <- no content block emitted

applyPatch returns false because the context lines no longer match the file (their indentation was stripped by trimDiff). With fuzzFactor: 0 (jsdiff default) there is no tolerance, so the hunk can't be located.

Trigger condition (deterministic, not a race)

The bug fires whenever all non-blank lines in the hunk are indented, i.e. the changed line(s) plus the 4-line context window on each side never include a zero-indent line. This is the common case: any edit inside a function/method body more than ~4 lines deep from its signature.

Standalone repro (no LLM needed)

import { applyPatch, createTwoFilesPatch } from "diff"

// trimDiff, copied verbatim from packages/opencode/src/tool/edit.ts
function trimDiff(diff) {
  const lines = diff.split("\n")
  const contentLines = lines.filter(
    (l) => (l.startsWith("+") || l.startsWith("-") || l.startsWith(" ")) &&
           !l.startsWith("---") && !l.startsWith("+++"),
  )
  if (contentLines.length === 0) return diff
  let min = Infinity
  for (const line of contentLines) {
    const c = line.slice(1)
    if (c.trim().length > 0) {
      const m = c.match(/^(\s*)/); if (m) min = Math.min(min, m[1].length)
    }
  }
  if (min === Infinity || min === 0) return diff
  return lines.map((l) =>
    (l.startsWith("+") || l.startsWith("-") || l.startsWith(" ")) &&
    !l.startsWith("---") && !l.startsWith("+++")
      ? l[0] + l.slice(1).slice(min) : l,
  ).join("\n")
}

// Change happens deep inside a function so the hunk's context window
// never reaches a zero-indent line.
const old =
`function compute() {
    const v0 = 0
    const v1 = 1
    const v2 = 2
    const v3 = 3
    const v4 = 4
    const v5 = 5
    const v6 = 6
    const v7 = 7
    const v8 = 8
    const v9 = 9
    const v10 = 10
    return sum
}
`
const next = old.replace("    const v5 = 5", "    const v5 = 50")

const raw = createTwoFilesPatch("f", "f", old, next)
const trimmed = trimDiff(raw)

console.log("applyPatch(raw):     ", applyPatch(old, raw) === false ? "FAILED" : "OK")
console.log("applyPatch(trimmed): ", applyPatch(old, trimmed) === false ? "FAILED  <- no preview" : "OK")

Output:

applyPatch(raw):      OK
applyPatch(trimmed):  FAILED  <- no preview

History

  • trimDiff was added in c1250abdf ("implemented diff trimming") for readability.
  • diffContentForPatch (the applyPatch reconstruction) was introduced by fix(acp): enrich permission prompts #34079 ("fix(acp): enrich permission prompts"). The two interact badly: trimDiff makes the patch non-applicable, so applyPatch fails.

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions