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;