From e42353a79ba8f49ad01198748a4c830f6ad6ea94 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 17 Jun 2026 02:50:40 +0000 Subject: [PATCH] fix(hashline): recorded acp read lines Merge displayed bridge-backed range and multi-range read lines into the existing hashline snapshot provenance so INS.POST anchors pass visible-line validation after ACP reads. Fixes #2773 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/edit/file-snapshot-store.ts | 15 ++++- packages/coding-agent/src/tools/read.ts | 26 +++++++++ .../test/edit/seen-line-guard.test.ts | 58 +++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce58d520d..a60721d40 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed hashline visible-line validation for ACP editor reads so `INS.POST` anchors displayed by bridge-backed range and multi-range `read` output are merged into the session snapshot before `edit` validates them ([#2773](https://github.com/can1357/oh-my-pi/issues/2773)). + ## [16.0.1] - 2026-06-15 ### Breaking Changes diff --git a/packages/coding-agent/src/edit/file-snapshot-store.ts b/packages/coding-agent/src/edit/file-snapshot-store.ts index b3f9d9279..a1a94b0d9 100644 --- a/packages/coding-agent/src/edit/file-snapshot-store.ts +++ b/packages/coding-agent/src/edit/file-snapshot-store.ts @@ -116,6 +116,17 @@ export function parseSeenLinesFromHashlineBody(body: string): number[] { return seen; } +/** Merge explicit 1-indexed displayed lines into a recorded hashline snapshot. */ +export function recordSeenLines( + session: FileSnapshotStoreOwner, + absolutePath: string, + tag: string, + lines: readonly number[], +): void { + if (lines.length === 0) return; + getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, lines); +} + /** * Attach the lines a read displayed to the snapshot it minted, so the patcher * can reject edits anchored on lines the model never saw. Best-effort: a no-op @@ -128,7 +139,5 @@ export function recordSeenLinesFromBody( tag: string, body: string, ): void { - const seen = parseSeenLinesFromHashlineBody(body); - if (seen.length === 0) return; - getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, seen); + recordSeenLines(session, absolutePath, tag, parseSeenLinesFromHashlineBody(body)); } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index dcccd57e9..e5d12b5e6 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -14,6 +14,7 @@ import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot, + recordSeenLines, recordSeenLinesFromBody, SNAPSHOT_MAX_BYTES, } from "../edit/file-snapshot-store"; @@ -288,6 +289,20 @@ function countTextLines(text: string): number { return text.split("\n").length; } +function contiguousLineNumbers(startLine: number, count: number): number[] { + const lines: number[] = []; + for (let offset = 0; offset < count; offset++) lines.push(startLine + offset); + return lines; +} + +function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] { + const lines: number[] = []; + for (const entry of entries) { + if (entry.kind === "line") lines.push(entry.lineNumber); + } + return lines; +} + /** Inclusive line range describing one elided span in a structural summary. */ interface ElidedRange { start: number; @@ -1041,8 +1056,10 @@ export class ReadTool implements AgentTool { ) : undefined; let emittedHashlineHeader = false; + let seenLines: number[] | undefined; const formatText = (content: string, startNum: number): string => { details.displayContent = { text: content, startLine: startNum }; + if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, countTextLines(content)); const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers); if (!hashContext || emittedHashlineHeader) return formatted; emittedHashlineHeader = true; @@ -1054,6 +1071,7 @@ export class ReadTool implements AgentTool { text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum, }; + if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries); const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); if (!hashContext || emittedHashlineHeader) return formatted; emittedHashlineHeader = true; @@ -1121,6 +1139,9 @@ export class ReadTool implements AgentTool { : formatLineEntries(buildLineEntries(endLine), startLineDisplay); } + if (hashContext?.tag && options.sourcePath && seenLines) { + recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines); + } resultBuilder.text(outputText); if (truncationInfo) { resultBuilder.truncation(truncationInfo.result, truncationInfo.options); @@ -1165,6 +1186,7 @@ export class ReadTool implements AgentTool { : undefined; let emittedHashlineHeader = false; + let seenLines: number[] | undefined; const resultBuilder = toolResult(details); if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath); if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl); @@ -1190,6 +1212,7 @@ export class ReadTool implements AgentTool { outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : ""; } else if (visibleSpans.length > 0) { const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath }); + if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries); const firstLine = entries.find(entry => entry.kind === "line"); if (firstLine?.kind === "line") { details.displayContent = { @@ -1208,6 +1231,9 @@ export class ReadTool implements AgentTool { } const finalText = notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText; + if (hashContext?.tag && options.sourcePath && seenLines) { + recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines); + } resultBuilder.text(finalText); return resultBuilder.done(); } diff --git a/packages/coding-agent/test/edit/seen-line-guard.test.ts b/packages/coding-agent/test/edit/seen-line-guard.test.ts index 131ba9e85..602ee3e91 100644 --- a/packages/coding-agent/test/edit/seen-line-guard.test.ts +++ b/packages/coding-agent/test/edit/seen-line-guard.test.ts @@ -22,6 +22,17 @@ function createSession(cwd: string): ToolSession { } as ToolSession; } +function createBridgeSession(cwd: string, content: string): ToolSession { + const bridge = { + capabilities: { readTextFile: true }, + readTextFile: async () => content, + }; + return { + ...createSession(cwd), + getClientBridge: () => bridge, + } as ToolSession; +} + function execOptions(input: string, session: ToolSession): ExecuteHashlineSingleOptions { return { session, @@ -110,6 +121,53 @@ describe("read → edit seen-line guard", () => { await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 2.=2:\n+EDITED`, session)); expect(await Bun.file(file).text()).toContain("EDITED"); }); + + it("merges displayed lines from ACP bridge range reads into existing provenance", async () => { + const file = path.join(tmpDir, "notes.txt"); + await Bun.write(file, CONTENT); + const session = createBridgeSession(tmpDir, CONTENT); + const store = getFileSnapshotStore(session); + const tag = store.record(canonicalSnapshotKey(file), CONTENT, [12]); + + const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); + expect(tagFromOutput(resultText(read))).toBe(tag); + + const seen = store.byHash(canonicalSnapshotKey(file), tag)?.seenLines; + expect(seen?.has(2)).toBe(true); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nINS.POST 2:\n+EDITED`, session)); + expect(await Bun.file(file).text()).toContain("line 2\nEDITED"); + }); + + it("merges displayed lines from ACP bridge multi-range reads into existing provenance", async () => { + const file = path.join(tmpDir, "src/main.c"); + const lines = Array.from({ length: 1300 }, (_, i) => `\tline_${i + 1}();`); + lines[1121] = "\tconfigure_gpio();"; + lines[1287] = "\tbeep_3k8hz_on();"; + lines[1289] = "\tk_sleep(K_MSEC(300));"; + lines[1290] = "\tbeep_3k8hz_off();"; + const content = `${lines.join("\n")}\n`; + await Bun.write(file, content); + const session = createBridgeSession(tmpDir, content); + const store = getFileSnapshotStore(session); + const tag = store.record(canonicalSnapshotKey(file), content, [1288, 1289, 1290, 1291]); + + const read = await new ReadTool(session).execute("r1", { path: `${file}:1118-1126,1284-1292` }); + const text = resultText(read); + expect(tagFromOutput(text)).toBe(tag); + expect(text).toContain("1122:\tconfigure_gpio();"); + + const seen = store.byHash(canonicalSnapshotKey(file), tag)?.seenLines; + expect(seen?.has(1122)).toBe(true); + await executeHashlineSingle( + execOptions( + `[src/main.c#${tag}]\nINS.POST 1122:\n+\tbeep_3k8hz_on();\n+\tk_sleep(K_MSEC(300));\n+\tbeep_3k8hz_off();\nDEL 1288.=1291`, + session, + ), + ); + const edited = await Bun.file(file).text(); + expect(edited).toContain("\tconfigure_gpio();\n\tbeep_3k8hz_on();\n\tk_sleep(K_MSEC(300));\n\tbeep_3k8hz_off();"); + expect(edited).not.toContain("\tbeep_3k8hz_on();\n\tline_1289();\n\tk_sleep(K_MSEC(300));"); + }); }); describe("search → edit seen-line guard", () => {