From b278ae85d182edac2b6db58ef93a71dcf550959b Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 5 Mar 2026 02:00:01 +0100 Subject: [PATCH] fix(coding-agent): corrected off-by-one range errors in hashline edits - Added auto-correction logic for off-by-one range start errors in hashline edits. - Added safety check to prevent false-positive auto-correction when position already includes boundary. - Added test coverage for off-by-one range correction and duplicate leading line handling. - Extracted turn-aborted-guidance prompt to external markdown file for better maintainability. --- .../ai/src/prompts/turn-aborted-guidance.md | 4 +++ .../ai/src/providers/transform-messages.ts | 19 ++-------- packages/coding-agent/CHANGELOG.md | 5 +++ packages/coding-agent/src/patch/hashline.ts | 29 +++++++++++++-- .../coding-agent/test/core/hashline.test.ts | 36 +++++++++++++++++++ 5 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 packages/ai/src/prompts/turn-aborted-guidance.md diff --git a/packages/ai/src/prompts/turn-aborted-guidance.md b/packages/ai/src/prompts/turn-aborted-guidance.md new file mode 100644 index 000000000..82dcc075b --- /dev/null +++ b/packages/ai/src/prompts/turn-aborted-guidance.md @@ -0,0 +1,4 @@ + +The previous turn was aborted. Any running tools/commands were terminated. +If tools were aborted, they may have partially executed; verify current state before retrying. + diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index eb81e48c5..5f229900c 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -1,18 +1,5 @@ -import type { - Api, - AssistantMessage, - DeveloperMessage, - Message, - Model, - ToolCall, - ToolResultMessage, -} from "../types"; - -const TURN_ABORTED_GUIDANCE = - "\n" + - "The previous turn was aborted. Any running tools/commands were terminated. " + - "If tools were aborted, they may have partially executed; verify current state before retrying.\n" + - ""; +import turnAbortedGuidance from "../prompts/turn-aborted-guidance.md" with { type: "text" }; +import type { Api, AssistantMessage, DeveloperMessage, Message, Model, ToolCall, ToolResultMessage } from "../types"; const enum ToolCallStatus { /** Tool call has received a result (real or synthetic for orphan) */ @@ -184,7 +171,7 @@ export function transformMessages( // Inject turn_aborted guidance marker as developer message result.push({ role: "developer", - content: TURN_ABORTED_GUIDANCE, + content: turnAbortedGuidance, timestamp: assistantMsg.timestamp + 1, } as DeveloperMessage); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1943e4616..9c1b73a9a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Support for Python code execution messages with output display and error handling @@ -19,6 +20,10 @@ - Removed `codexInjectionInfo` from session data destructuring - Removed OpenAI Codex-specific bridge prompt UI from model change entries +### Fixed + +- Auto-corrected off-by-one range start errors in hashline edits that would duplicate preceding lines + ## [13.9.0] - 2026-03-05 ### Added diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index f8718257d..175e7a67a 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -458,6 +458,18 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn // Edit Application // ═══════════════════════════════════════════════════════════════════════════ +const MIN_AUTOCORRECT_LENGTH = 2; + +function shouldAutocorrect(line: string, otherLine: string): boolean { + if (!line || line !== otherLine) return false; + line = line.trim(); + if (line.length < MIN_AUTOCORRECT_LENGTH) { + // if brace, we allow + return line.endsWith("}") || line.endsWith(")"); + } + return true; +} + /** * Apply an array of hashline edits to file content. * @@ -630,9 +642,7 @@ export function applyHashlineEdits( const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd(); const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd(); if ( - trailingReplacementLine && - nextSurvivingLine && - trailingReplacementLine === nextSurvivingLine && + shouldAutocorrect(trailingReplacementLine, nextSurvivingLine) && // Safety: only correct when end-line content differs from the duplicate. // If end already points to the boundary, matching next line is coincidence. fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine @@ -642,6 +652,19 @@ export function applyHashlineEdits( `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`, ); } + const leadingReplacementLine = newLines[0]?.trimEnd(); + const prevSurvivingLine = fileLines[edit.pos.line - 2]?.trimEnd(); + if ( + shouldAutocorrect(leadingReplacementLine, prevSurvivingLine) && + // Safety: only correct when pos-line content differs from the duplicate. + // If pos already points to the boundary, matching prev line is coincidence. + fileLines[edit.pos.line - 1]?.trimEnd() !== leadingReplacementLine + ) { + newLines.shift(); + warnings.push( + `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed leading replacement line "${leadingReplacementLine}" that duplicated preceding surviving line`, + ); + } fileLines.splice(edit.pos.line - 1, count, ...newLines); trackFirstChanged(edit.pos.line); } diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 4e9336ed5..80c37684e 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -580,6 +580,42 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.lines).toBe("ALPHA\n\n\ngamma"); expect(result.warnings).toBeUndefined(); }); + + it("auto-corrects off-by-one range start that would duplicate a preceding line", () => { + // Reproduces the failure from assistant history: model anchors at line N+1 but + // opens the replacement block with the same content as line N, duplicating it. + const content = "if (x) {\n oldBody();\n}\nafter();"; + const edits: HashlineEdit[] = [ + { + op: "replace", + pos: makeTag(2, " oldBody();"), + end: makeTag(3, "}"), + lines: ["if (x) {", " newBody();", "}"], + }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.lines).toBe("if (x) {\n newBody();\n}\nafter();"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings?.[0]).toContain("Auto-corrected range replace"); + expect(result.warnings?.[0]).toContain('"if (x) {"'); + }); + + it("does not auto-correct leading line when pos already includes the boundary line", () => { + // Safety guard: the first replacement line equals the line before pos, but pos itself + // already has the same content — coincidence, not off-by-one. + const content = "}\n}\nafter();"; + const edits: HashlineEdit[] = [ + { + op: "replace", + pos: makeTag(2, "}"), + end: makeTag(2, "}"), + lines: ["}", "// inserted"], + }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.lines).toBe("}\n}\n// inserted\nafter();"); + expect(result.warnings).toBeUndefined(); + }); it("auto-corrects leading escaped tab indentation by default", () => { const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;