Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions crates/pi-edit/src/modes/hashline/patcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ fn rescue_shifted_anchors(
Ok(rescued)
}

/// Reject anchors the tagged snapshot never displayed, revealing their content
/// inline. `None` provenance (tag issued without a display record) skips the
/// guard; an empty set (a `write` tag) means no line was displayed, so every
/// anchor is unseen.
fn assert_seen_lines(
section: &PatchSection,
expected: &str,
Expand All @@ -359,9 +363,6 @@ fn assert_seen_lines(
let Some(seen) = snapshot.seen_lines else {
return Ok(());
};
if seen.is_empty() {
return Ok(());
}
let mut unseen = section
.collect_anchor_lines()?
.into_iter()
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

- Background task guidance now lives in the Task tool description (and in same-name extension replacements that delegate to Task) instead of being repeated in every spawn acknowledgment ([#15477](https://github.com/can1357/oh-my-pi/pull/15477) by [@iliaal](https://github.com/iliaal))

### Fixed

- Line-anchored edits against the tag a `write` returns are now rejected with the anchor lines revealed instead of applying at guessed line numbers ([#15666](https://github.com/can1357/oh-my-pi/issues/15666))

## [18.10.4] - 2026-10-13

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,18 @@ describe("registerFileWriteFallback end-to-end (real extension, real session)",
const headerLine = resultText(writeResult).split("\n")[0] ?? "";
expect(HASHLINE_HEADER_LINE.test(headerLine)).toBe(true);

// (iii) a subsequent hashline edit on the SAME real path works — this only
// holds if the write tool recorded its snapshot under `targetPath` itself
// (not a temp path the fallback happened to route through).
// (iii) a subsequent hashline edit on the SAME real path works after
// its anchor is revealed. The rejection requires the write snapshot
// to be recorded under `targetPath`, not a temporary broker path.
const editTool = session.getToolByName("edit") as AgentTool | undefined;
expect(editTool).toBeDefined();
const editInput = `${headerLine}\nPUT 1-1:\n+export const value = 43;\n`;
const editResult = await editTool!.execute("call-edit-1", { input: editInput });
const rejected = await editTool!.execute("call-edit-1", { input: editInput });
expect(rejected.isError).toBe(true);
expect(resultText(rejected)).toContain("1:export const value = 42;");
expect(fs.readFileSync(targetPath, "utf8")).toBe(content);

const editResult = await editTool!.execute("call-edit-2", { input: editInput });
expect(editResult.isError).not.toBe(true);
expect(fs.readFileSync(targetPath, "utf8")).toBe("export const value = 43;\n");
} finally {
Expand Down
23 changes: 14 additions & 9 deletions packages/coding-agent/test/write-hashline-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe("write tool hashline header", () => {
expect(snapshot).toBe(content);
});

it("makes the post-write tag usable by the hashline patcher", async () => {
it("guards line-anchored edits on the post-write tag until the anchor is revealed", async () => {
const filePath = path.join(tmpDir, "config.ts");
const session = createSession(tmpDir);
const tool = new WriteTool(session);
Expand All @@ -80,13 +80,18 @@ describe("write tool hashline header", () => {
const headerLine = resultText(writeResult).split("\n")[0] ?? "";
expect(HASHLINE_HEADER_LINE.test(headerLine)).toBe(true);

// Apply a hashline patch immediately, using only the tag the write tool
// returned — no intervening `read`.
// A write displays no numbered lines: anchoring on its tag without a
// `read` is rejected with the anchor revealed, and the file is untouched.
const patchInput = `${headerLine}\nPUT 1-1:\n+export const enabled = true;\n`;
await new EditTool(session, "hashline").execute("call-2", { input: patchInput });

const final = await fs.readFile(filePath, "utf8");
expect(final).toBe("export const enabled = true;\n");
const edit = new EditTool(session, "hashline");
const rejected = resultText(await edit.execute("call-2", { input: patchInput }));
expect(rejected).toContain("never displayed");
expect(rejected).toContain("1:export const enabled = false;");
expect(await fs.readFile(filePath, "utf8")).toBe(content);

// The reveal counts as having seen the line, so the same patch now applies.
await edit.execute("call-3", { input: patchInput });
expect(await fs.readFile(filePath, "utf8")).toBe("export const enabled = true;\n");
});

it("names a local:// write by its URL, and the header round-trips through edit and write", async () => {
Expand All @@ -100,9 +105,9 @@ describe("write tool hashline header", () => {
expect(writeLine).toBe(`Successfully wrote ${content.length} bytes to local://notes.ts`);

await new EditTool(session, "hashline").execute("call-2", {
input: `${headerLine}\nPUT 1-1:\n+export const enabled = true;\n`,
input: `${headerLine}\nPUT >$:\n+export const flag = true;\n`,
});
expect(await fs.readFile(backingPath, "utf8")).toBe("export const enabled = true;\n");
expect(await fs.readFile(backingPath, "utf8")).toBe(`${content}export const flag = true;\n`);

// The URL-form header also addresses the same file as a `write` path.
await new WriteTool(session).execute("call-3", { path: headerLine, content: "export const v = 2;\n" });
Expand Down
Loading