Skip to content

fix(widgets): expose per-edit application detail on patchWidget results#33

Open
nsyring wants to merge 1 commit intoagent0ai:mainfrom
nsyring:fix/widget-patch-edit-detail
Open

fix(widgets): expose per-edit application detail on patchWidget results#33
nsyring wants to merge 1 commit intoagent0ai:mainfrom
nsyring:fix/widget-patch-edit-detail

Conversation

@nsyring
Copy link
Copy Markdown

@nsyring nsyring commented Apr 26, 2026

fix(widgets): expose per-edit application detail on patchWidget results

Summary

patchWidget previously returned a single status line that confirmed source-level success without telling the agent which kinds of edits were just applied or how many of them. With multi-edit patches now common in agent-generated calls (see widget-skill read before mutate flow), this PR adds a structured appliedEdits: [...] field on the result envelope and a short aggregated fragment in the existing status string.

After:

Widget "X" patched, 1 edit applied (1 find/replace), 501 renderer lines (was 500, +1), rendered ok, loaded to TRANSIENT.
Widget "X" patched, 3 edits applied (2 find/replace, 1 line replace), rendered ok, loaded to TRANSIENT.

The structured appliedEdits array is also available on the tool result for skill rules and eval harnesses that want to read individual edit positions, replaced lengths, or removed/inserted line counts without parsing the status string.

When patch validation fails, the error message now identifies which edit caused the failure by index plus a short edit description, so the agent does not have to guess which entry in a multi-edit array it has to fix.

Why

The widget-skill SKILL.md already requires that the agent build a fresh source view via readWidget before patching, and the patch vs rewrite rules pin the canonical edit shapes. But the validator and runtime were both opaque about which edits applied and which edit failed:

Today: A patchWidget call with five edits either succeeds or throws once with a generic "find text was not found" / "from line is outside range" message. Three problems show up in real agent traces:

  1. The agent cannot tell from a patched, rendered ok status whether the call applied one targeted change or twelve. It re-derives a mental model from the chat history, which can drift.
  2. When validation fails on a multi-edit call, the error names a class of problem ("find text was not found") but not which edit had the bad find text. With four find/replace entries in the call, the agent has to retry-and-narrow.
  3. A find text that matches twice produces "find text is ambiguous" without telling the agent how many duplicates exist or where they are. The agent often retries with a slightly longer snippet that also matches twice.

This PR closes those three gaps without changing the atomic semantics of patchWidget (the call is still all-or-nothing on the storage side; partial application is out of scope).

What changed

app/L0/_all/mod/_core/spaces/storage.js (+91 / -16)

  • describeWidgetPatchEdit(edit) and buildWidgetPatchEditErrorPrefix(editIndex, edit) are new helpers that turn an edit's user-facing fields (find, from, to, line, range) into a short label like find "snake_color = 'gr…" or line 264-265.
  • normalizeWidgetPatchEdit(edit, lineCount, sourceText, editIndex = -1) accepts an edit index. All thrown errors from per-edit normalization now prepend Edit ${editIndex+1} (${description}): so the agent reads Edit 3 (find "const max = ..."): exact widget snippet edit \find` text was not found in the readable renderer.` instead of the generic message. Cross-edit validation errors (overlap, mode-mixing, whole-renderer rewrite) reference two edits by their existing per-span labels and intentionally stay un-prefixed; their wording already names both spans.
  • normalizeWidgetTextPatchEdit(...) carries the same prefix on its three failure paths and also includes the actual occurrence count in the ambiguity message: find text matches 4 renderer locations and is ambiguous instead of just is ambiguous.
  • applyWidgetPatchEdits(widgetRecord, edits) now returns { rendererSource, appliedEdits }. appliedEdits is an array of {kind, ...details} entries describing each successfully applied edit:
    • {kind: "find/replace", lineNumber, findLength, replacedLength, contentLength} for snippet edits, where lineNumber is the renderer line the matched snippet started on (multi-line matches report their start line) and findLength is the length of the matched find text — useful for skill rules that want to detect near-miss patterns
    • {kind: "replace", from, to, removedLines, insertedLines} for ranged line edits
    • {kind: "insert", from, insertedLines} for line inserts
  • buildWidgetWriteResult(spaceRecord, widgetId, extras = null) accepts a third argument and threads appliedEdits from the caller into the result envelope. Existing two-arg callers (upsertWidget, upsertWidgets, removeWidget paths) are unchanged.
  • patchWidget(...) captures the applyWidgetPatchEdits outcome and passes appliedEdits to buildWidgetWriteResult. The renderer source threading is otherwise identical.

app/L0/_all/mod/_core/spaces/store.js (+47 / -7)

  • formatWidgetAppliedEditsFragment(appliedEdits) aggregates the per-edit detail array into a short status fragment. It groups by edit kind, prints a count, and degrades to the empty string when no appliedEdits is present.
  • formatWidgetOperationStatusText(...) accepts a new appliedEdits option and inserts the fragment between the verb (patched) and the render-status fragment (rendered ok). The fragment is empty for non-patch operations (renderWidget, upsertWidget, reloadWidget) so their status text is byte-identical to today.
  • buildWidgetToolResult(...) reads appliedEdits off the result envelope and forwards it to the formatter.

Behavior matrix

Case Status before Status after
patchWidget 1 find/replace edit patched, rendered ok, ... patched, 1 edit applied (1 find/replace), rendered ok, ...
patchWidget 3 mixed edits patched, rendered ok, ... patched, 3 edits applied (2 find/replace, 1 line replace), rendered ok, ...
renderWidget / upsertWidget unchanged unchanged
reloadWidget unchanged unchanged
Failed patch, edit 3 of 5 had bad find Error: ... find text was not found ... Error: Edit 3 (find "const max = ..."): exact widget snippet edit \find` text was not found ...`
Failed patch, edit had ambiguous find with 4 matches Error: ... find text is ambiguous ... Error: Edit 1 (find "const x"): exact widget snippet edit \find` text matches 4 renderer locations and is ambiguous ...`

No callers of formatWidgetOperationStatusText or buildWidgetWriteResult need to change. The structural appliedEdits field is purely additive.

Test plan

  • node --check on both modified files passes
  • formatWidgetAppliedEditsFragment sanity table verified for empty, undefined, single-edit, multi-edit, and mixed-kind cases
  • Patch validator and renderer-source threading remain functionally unchanged for atomic patch success and atomic failure paths; only the error messages and the result envelope are richer
  • Manual verification across two providers in npm run desktop:pack builds:
    • gpt-5.4 via OpenAI Codex provider — multi-edit patches now show the kind breakdown in the status; a deliberately broken find snippet returns an error that names the edit index, helping the agent fix only that entry on retry
    • Local qwen3-coder model — same behavior; the smaller model reads the structured fragment cleanly and uses the edit-index in its retry reasoning

Relationship to other PRs

Compatible with PR #6 ("prevent agent widget editing loops"). The widget-skill rule "If patchWidget() or renderWidget() says No files were written, the old widget file is still the source of truth. Fix and retry" pattern-matches on the keyword phrase was not found. The new Edit N (description): prefix is prepended to the existing message, so every keyword phrase the skill rules match on (was not found, is ambiguous, outside the readable renderer range) is preserved verbatim. The agent additionally gets the index and short label of the failing edit so it can fix only that entry on retry instead of resubmitting the whole batch.

Out of scope (possible follow-ups)

  • Partial application (allow a multi-edit call to apply 4 of 5 edits and report the rejected one explicitly). This would change the atomic semantics of patchWidget and is a deliberate separate discussion.
  • "Closest match candidates" on find text not found. The current PR includes the location count for ambiguous matches but does not yet suggest near-misses; a Levenshtein or token-based suggestion would be useful diagnostic but adds nontrivial code.
  • Surfacing appliedEdits in the Current Widget transient envelope. The structured field on the tool result is the canonical surface; transient header is decorative.

🤖 Generated with Claude Code

patchWidget previously confirmed source-level success without telling
the agent which kinds of edits were just applied or which entry caused
a multi-edit failure. With agent-generated multi-edit patches now
common (post the read-before-mutate skill rules), three concrete gaps
showed up in real traces:

- "patched, rendered ok" did not distinguish a single targeted change
  from a twelve-edit refactor
- a thrown "find text was not found" did not name the failing edit
  among 4 find/replace entries; the agent had to retry-and-narrow
- "find text is ambiguous" did not say how many duplicates existed

This change closes those gaps without altering the atomic semantics of
patchWidget (still all-or-nothing on the storage side):

- describeWidgetPatchEdit and buildWidgetPatchEditErrorPrefix turn an
  edit's user-facing fields into a short label, used to prefix every
  validation error with the edit index plus a description (e.g.
  Edit 3 (find "const max = ..."): exact widget snippet edit `find`
  text was not found in the readable renderer)
- the ambiguity error now names the actual occurrence count
- applyWidgetPatchEdits returns { rendererSource, appliedEdits }; the
  appliedEdits array describes each applied edit with its kind and
  position-relevant details (lineNumber for find/replace, from/to/
  removedLines/insertedLines for line edits)
- patchWidget threads appliedEdits through buildWidgetWriteResult into
  the result envelope so skill rules and eval harnesses can read
  structured detail without parsing the status string
- formatWidgetOperationStatusText aggregates appliedEdits into a short
  fragment between the verb and the render-status fragment:
  "patched, 3 edits applied (2 find/replace, 1 line replace),
  rendered ok, ..."

renderWidget, upsertWidget, and reloadWidget status text is
byte-identical to today (no appliedEdits on those paths).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant