From 61137c43cd5f6ace1478584d549e43254e4643d6 Mon Sep 17 00:00:00 2001 From: "Syring, Nikolas" Date: Sat, 25 Apr 2026 16:05:15 +0200 Subject: [PATCH] fix(widgets): reject unknown patch edit fields, list accepted aliases The widget patch validator previously accepted edits with unrecognized field names, silently producing destructive results. An LLM emitting `{ path: "renderer", line: 264, replaceWith: "..." }` would have its `replaceWith` payload dropped (not in the recognized aliases set). The remaining `line` field is read as both `from` and `to`, the absent replacement falls through to `contentLines: []`, and the validator reports success on what is actually a single-line deletion. The runtime prints the standard "saved, rendered ok" status and the agent treats the destructive patch as the intended result. Two changes close the loophole: - `WIDGET_PATCH_KNOWN_EDIT_FIELDS` lists every recognized edit field (content aliases, coordinate aliases, snippet aliases); the new `listUnknownWidgetPatchEditFields(...)` helper returns anything outside that set, and `normalizeWidgetPatchEdit(...)` throws on the first unknown field with a message that lists the valid replacement and coordinate aliases so the agent can self-correct. - `replaceWith`, `replacement`, and `newText` are added to the recognized content aliases. LLM patch outputs default to those names from VS Code, OpenAI tool-call, and similar conventions; they unblock the common patch shape without forcing every caller to rewrite the field name first. The existing insert-without-replacement error message is widened to list all accepted replacement-text aliases instead of only `content`. No API change, no Skill rewrite, no tests touched. --- .../spaces/ext/skills/space-widgets/SKILL.md | 4 +- app/L0/_all/mod/_core/spaces/storage.js | 89 +++++++++++++------ 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/app/L0/_all/mod/_core/spaces/ext/skills/space-widgets/SKILL.md b/app/L0/_all/mod/_core/spaces/ext/skills/space-widgets/SKILL.md index 7e31e6ee..aebb39d6 100644 --- a/app/L0/_all/mod/_core/spaces/ext/skills/space-widgets/SKILL.md +++ b/app/L0/_all/mod/_core/spaces/ext/skills/space-widgets/SKILL.md @@ -139,7 +139,9 @@ patch vs rewrite - from and to are inclusive zero-based renderer line numbers - Omit to to insert before from - Omit content on a ranged line edit to delete -- Common line aliases like line, startLine/endLine, range, text, and replace are tolerated, but prefer the canonical shapes above +- Common coordinate aliases like line, startLine/endLine, and range are tolerated, but prefer the canonical shapes above +- Common content aliases like text, replace, value, replaceWith, replacement, and newText are tolerated, but prefer canonical content +- Other field names on an edit object are rejected fast with a list of accepted aliases. Do not invent fields like path, replaceText, or body — they are unrecognized and the edit will be refused before any line is touched - Do not mix exact find edits and line edits in the same call - Do not overlap edits - The runtime applies edits from higher line numbers down to lower ones diff --git a/app/L0/_all/mod/_core/spaces/storage.js b/app/L0/_all/mod/_core/spaces/storage.js index 853f8b45..e3359398 100644 --- a/app/L0/_all/mod/_core/spaces/storage.js +++ b/app/L0/_all/mod/_core/spaces/storage.js @@ -618,33 +618,60 @@ function isWidgetPatchTextLike(value) { return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } -function readWidgetPatchContentField(edit = {}) { - if (hasOwnWidgetPatchField(edit, "content")) { - return { - key: "content", - value: edit.content - }; - } - - if (hasOwnWidgetPatchField(edit, "text")) { - return { - key: "text", - value: edit.text - }; +// Field names accepted as replacement-text aliases on a widget patch edit. +// `content` is canonical; `text`, `replace`, `value`, `replaceWith`, +// `replacement`, and `newText` are tolerated because LLM patch outputs +// frequently default to those names from VS Code, OpenAI tool-call, +// jsondiffpatch, and similar conventions. Listing them explicitly here +// also lets the validator surface a precise error message when none of the +// aliases is set on a `replace`-style line edit. +const WIDGET_PATCH_CONTENT_FIELD_ALIASES = Object.freeze([ + "content", + "text", + "replace", + "value", + "replaceWith", + "replacement", + "newText" +]); + +// All field names the widget patch validator recognizes on an edit object. +// Anything outside this set is unknown and almost certainly a typo'd content +// alias from an LLM (`with`, `replaceText`, `body`, `newContent`, ...). The +// validator surfaces unknown fields as an explicit error so the agent does +// not silently delete renderer lines when its replacement text is rejected +// by name mismatch. +const WIDGET_PATCH_KNOWN_EDIT_FIELDS = Object.freeze([ + ...WIDGET_PATCH_CONTENT_FIELD_ALIASES, + "find", + "search", + "from", + "to", + "line", + "startLine", + "endLine", + "range", + "mode", + "kind" +]); + +function listUnknownWidgetPatchEditFields(edit = {}) { + if (!edit || typeof edit !== "object" || Array.isArray(edit)) { + return []; } - if (hasOwnWidgetPatchField(edit, "replace")) { - return { - key: "replace", - value: edit.replace - }; - } + const knownFieldSet = new Set(WIDGET_PATCH_KNOWN_EDIT_FIELDS); + return Object.keys(edit).filter((key) => !knownFieldSet.has(key)); +} - if (hasOwnWidgetPatchField(edit, "value")) { - return { - key: "value", - value: edit.value - }; +function readWidgetPatchContentField(edit = {}) { + for (const aliasKey of WIDGET_PATCH_CONTENT_FIELD_ALIASES) { + if (hasOwnWidgetPatchField(edit, aliasKey)) { + return { + key: aliasKey, + value: edit[aliasKey] + }; + } } return null; @@ -732,6 +759,16 @@ function normalizeWidgetTextPatchEdit(edit, sourceText) { function normalizeWidgetPatchEdit(edit, lineCount, sourceText) { const normalizedEdit = edit && typeof edit === "object" ? edit : {}; + const unknownFields = listUnknownWidgetPatchEditFields(normalizedEdit); + if (unknownFields.length > 0) { + throw new Error( + `Widget patch edit has unknown field${unknownFields.length === 1 ? "" : "s"} ${unknownFields.map((name) => `\`${name}\``).join(", ")}. ` + + `Valid replacement-text fields are ${WIDGET_PATCH_CONTENT_FIELD_ALIASES.map((name) => `\`${name}\``).join(", ")}; ` + + `valid coordinates are \`from\`/\`to\` (canonical) or \`line\`/\`startLine\`/\`endLine\`/\`range\` aliases; ` + + `exact-snippet edits use \`find\` (or \`search\`) plus a replacement field.` + ); + } + if (hasOwnWidgetPatchField(normalizedEdit, "find") || hasOwnWidgetPatchField(normalizedEdit, "search")) { return normalizeWidgetTextPatchEdit(normalizedEdit, sourceText); } @@ -765,7 +802,9 @@ function normalizeWidgetPatchEdit(edit, lineCount, sourceText) { } if (!hasTo && !hasContent) { - throw new Error("Insert edits must include replacement text in `content`."); + throw new Error( + `Insert edits must include replacement text. Use \`content\` (canonical) or one of: ${WIDGET_PATCH_CONTENT_FIELD_ALIASES.slice(1).map((name) => `\`${name}\``).join(", ")}.` + ); } if (!hasTo) {