fix(hashline): prevented exposing terminal newline as an addressable row
- Added `splitAddressableFileLines` to strip terminal newlines from line addressability without removing genuine blank lines. - Updated coding-agent read tool context parsing to use addressable file lines.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import * as path from "node:path";
|
||||
import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
|
||||
import {
|
||||
formatHashlineHeader,
|
||||
formatNumberedLine,
|
||||
formatNumberedLines,
|
||||
splitAddressableFileLines,
|
||||
} from "@oh-my-pi/hashline";
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { canonicalSnapshotKey, getFileSnapshotStore, recordSeenLines } from "../edit/file-snapshot-store";
|
||||
import { normalizeToLF } from "../edit/normalize";
|
||||
@@ -290,7 +295,7 @@ export function buildInMemoryTextResult(
|
||||
): AgentToolResult<ReadToolDetails> {
|
||||
const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable });
|
||||
const details = options.details ?? {};
|
||||
const allLines = text.split("\n");
|
||||
const allLines = options.raw === true ? text.split("\n") : splitAddressableFileLines(text);
|
||||
const totalLines = allLines.length;
|
||||
details.totalLines = totalLines;
|
||||
// User-requested 0-indexed range start. Lines BEFORE this are leading
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { splitAddressableFileLines } from "@oh-my-pi/hashline";
|
||||
import { type } from "@oh-my-pi/omptype";
|
||||
import type {
|
||||
AgentTool,
|
||||
@@ -119,12 +120,17 @@ const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
|
||||
async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise<string[] | undefined> {
|
||||
if (fileSize > SNAPSHOT_MAX_BYTES) return undefined;
|
||||
try {
|
||||
return normalizeToLF(await Bun.file(absolutePath).text()).split("\n");
|
||||
return splitAddressableFileLines(normalizeToLF(await Bun.file(absolutePath).text()));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamFileLinesOptions {
|
||||
includeTerminalNewline?: boolean;
|
||||
stopScanAfterCollect?: boolean;
|
||||
}
|
||||
|
||||
async function streamLinesFromFile(
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
@@ -132,7 +138,7 @@ async function streamLinesFromFile(
|
||||
maxBytes: number,
|
||||
selectedLineLimit: number | null,
|
||||
signal?: AbortSignal,
|
||||
stopScanAfterCollect = false,
|
||||
options: StreamFileLinesOptions = {},
|
||||
): Promise<{
|
||||
lines: string[];
|
||||
totalFileLines: number;
|
||||
@@ -141,9 +147,12 @@ async function streamLinesFromFile(
|
||||
firstLinePreview?: { text: string; bytes: number };
|
||||
firstLineByteLength?: number;
|
||||
selectedBytesTotal: number;
|
||||
/** Whether the fully scanned source ended in a newline. */
|
||||
hasTrailingNewline: boolean;
|
||||
/** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */
|
||||
reachedEof: boolean;
|
||||
}> {
|
||||
const { includeTerminalNewline = false, stopScanAfterCollect = false } = options;
|
||||
const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
|
||||
const collectedLines: string[] = [];
|
||||
let lineIndex = 0;
|
||||
@@ -311,7 +320,7 @@ async function streamLinesFromFile(
|
||||
}
|
||||
}
|
||||
|
||||
if (reachedEof && (endedWithNewline || currentLineLength > 0 || !sawAnyByte)) {
|
||||
if (reachedEof && (currentLineLength > 0 || !sawAnyByte || (endedWithNewline && includeTerminalNewline))) {
|
||||
finalizeLine();
|
||||
}
|
||||
|
||||
@@ -330,6 +339,7 @@ async function streamLinesFromFile(
|
||||
firstLineByteLength,
|
||||
selectedBytesTotal,
|
||||
reachedEof,
|
||||
hasTrailingNewline: reachedEof && endedWithNewline,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -692,7 +702,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
maxBytesForRead,
|
||||
maxLines,
|
||||
signal,
|
||||
fileSize > SNAPSHOT_MAX_BYTES, // giant file: collected ranges don't need an exact EOF line count
|
||||
{ includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES },
|
||||
);
|
||||
totalFileLines = streamResult.totalFileLines;
|
||||
collectedLines = streamResult.lines;
|
||||
@@ -1256,7 +1266,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
maxBytesForRead,
|
||||
selectedLineLimit,
|
||||
undefined, // plain-file read: deterministic and fast, never abort mid-read
|
||||
fileSize > SNAPSHOT_MAX_BYTES, // giant file: don't scan to EOF just for an exact line count
|
||||
{ includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES },
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -1267,6 +1277,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
firstLinePreview,
|
||||
firstLineByteLength,
|
||||
reachedEof,
|
||||
hasTrailingNewline,
|
||||
} = streamResult;
|
||||
|
||||
// Check if offset is out of bounds - return graceful message instead of throwing
|
||||
@@ -1347,7 +1358,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
const tag = isWholeFile
|
||||
? getFileSnapshotStore(this.session).record(
|
||||
canonicalSnapshotKey(absolutePath),
|
||||
normalizeToLF(collectedLines.join("\n")),
|
||||
normalizeToLF(`${collectedLines.join("\n")}${hasTrailingNewline ? "\n" : ""}`),
|
||||
)
|
||||
: await recordFileSnapshot(this.session, absolutePath);
|
||||
if (tag) {
|
||||
@@ -1694,7 +1705,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
maxBytesForRead,
|
||||
selectedLineLimit,
|
||||
signal,
|
||||
artifact.size > SNAPSHOT_MAX_BYTES,
|
||||
{ includeTerminalNewline: rawSelector, stopScanAfterCollect: artifact.size > SNAPSHOT_MAX_BYTES },
|
||||
);
|
||||
const {
|
||||
lines: collectedLines,
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
|
||||
import {
|
||||
formatHashlineHeader,
|
||||
formatNumberedLines,
|
||||
type SnapshotStore,
|
||||
splitAddressableFileLines,
|
||||
} from "@oh-my-pi/hashline";
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { formatAge, formatBytes, isProbablyBinary, readImageMetadata } from "@oh-my-pi/pi-utils";
|
||||
@@ -270,7 +275,8 @@ export async function generateFileMentionMessages(
|
||||
const content = await Bun.file(absolutePath).text();
|
||||
const snapshotStore = options?.useHashLines ? options.snapshotStore : undefined;
|
||||
const normalized = snapshotStore ? normalizeToLF(content) : content;
|
||||
let { output, lineCount } = buildTextOutput(normalized);
|
||||
const displayText = snapshotStore ? splitAddressableFileLines(normalized).join("\n") : normalized;
|
||||
let { output, lineCount } = buildTextOutput(displayText);
|
||||
if (snapshotStore) {
|
||||
const tag = snapshotStore.record(canonicalSnapshotKey(absolutePath), normalized);
|
||||
output = `${formatHashlineHeader(resolvedPath, tag)}\n${formatNumberedLines(output)}`;
|
||||
|
||||
@@ -182,4 +182,25 @@ describe("read tool column truncation vs hashline snapshot", () => {
|
||||
const after = await fs.readFile(filePath, "utf8");
|
||||
expect(after).toBe(`intro\n${longLine}\nepilogue\n`);
|
||||
});
|
||||
|
||||
it("keeps a genuine blank line editable without exposing the EOF sentinel", async () => {
|
||||
const filePath = path.join(tmpDir, "eof-blank.txt");
|
||||
await fs.writeFile(filePath, "first\n\nlast\n");
|
||||
|
||||
const session = createSession(tmpDir);
|
||||
const readText = textOutput(await new ReadTool(session).execute("call-eof-blank", { path: filePath }));
|
||||
expect(readText).toContain("1:first\n2:\n3:last");
|
||||
expect(readText).not.toContain("\n4:");
|
||||
|
||||
const { header } = extractHeader(readText);
|
||||
await applyEditWithTag({
|
||||
session,
|
||||
tmpDir,
|
||||
filePath,
|
||||
header,
|
||||
patchBody: "CUT 2\n",
|
||||
});
|
||||
|
||||
expect(await fs.readFile(filePath, "utf8")).toBe("first\nlast\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stopped `formatNumberedLines` from exposing a terminal newline as an editable blank line; deleting that synthetic row previously produced a misleading no-op.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -13,6 +10,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed Rust lifetimes blinding the delimiter-balance scanner. `'` entered string state to end-of-line, so `&'static str {` hid its opening brace; a replacement range swallowing such a signature line looked balance-neutral and the mid-block advisory never fired, silently deleting the signature. Single-quote lexing is now language-aware: on `.rs` targets, `'` opens a literal only when it lexes as a real char literal (`'a'`, `'\n'`, `'\u{7FFF}'`), and lifetimes stay ordinary characters — apostrophes are never paired across lifetimes, which would swallow the delimiters between them (`<'a>(x: &'a str)`).
|
||||
- Fixed hashline reads exposing a terminal newline as an editable blank row. `splitAddressableFileLines` now removes that sentinel before consumers build line anchors while retaining genuine blank lines.
|
||||
|
||||
## [17.2.12] - 2026-08-08
|
||||
|
||||
|
||||
@@ -140,12 +140,19 @@ export function formatNumberedLine(lineNumber: number, line: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file text with hashline-mode line-number prefixes for display.
|
||||
* A terminal newline terminates the preceding line; it is not an addressable
|
||||
* blank line.
|
||||
* Split LF-delimited file text into lines hashline anchors can address.
|
||||
* A terminal newline terminates the preceding line; it is not content.
|
||||
*/
|
||||
export function formatNumberedLines(text: string, startLine = 1): string {
|
||||
export function splitAddressableFileLines(text: string): string[] {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Format file text with hashline-mode line-number prefixes for display. */
|
||||
export function formatNumberedLines(text: string, startLine = 1): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line, i) => formatNumberedLine(startLine + i, line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseLid,
|
||||
parsePatch,
|
||||
parsePatchStreaming,
|
||||
splitAddressableFileLines,
|
||||
Tokenizer,
|
||||
} from "@oh-my-pi/hashline";
|
||||
|
||||
@@ -105,9 +106,14 @@ describe("hashline format v4", () => {
|
||||
expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n");
|
||||
});
|
||||
|
||||
it("does not expose the terminal newline as an editable blank line", () => {
|
||||
expect(formatNumberedLines("a\nb\n")).toBe("1:a\n2:b");
|
||||
expect(formatNumberedLines("a\nb\n\n")).toBe("1:a\n2:b\n3:");
|
||||
it("separates terminal newline sentinels from addressable file lines", () => {
|
||||
expect(splitAddressableFileLines("a\nb\n")).toEqual(["a", "b"]);
|
||||
expect(splitAddressableFileLines("a\nb\n\n")).toEqual(["a", "b", ""]);
|
||||
});
|
||||
|
||||
it("keeps a selected terminal blank line when formatting", () => {
|
||||
const selected = splitAddressableFileLines("a\n\nb\n").slice(0, 2).join("\n");
|
||||
expect(formatNumberedLines(selected)).toBe("1:a\n2:");
|
||||
});
|
||||
|
||||
it("treats a cut range ending at the trailing sentinel as ending at the last real line", () => {
|
||||
|
||||
Reference in New Issue
Block a user