Merge PR #5812: fix(read): honor exact line selector bounds (@roboomp)

This commit is contained in:
can1357
2026-07-18 20:12:46 +02:00
7 changed files with 92 additions and 186 deletions
+3
View File
@@ -96,6 +96,9 @@
### Fixed
- Fixed the `/extensions` dashboard tab labeled "Agents (standard)" being confused with the `/agents` subagents feature — the `.agent`/`.agents` config-standard provider now presents as "Agent Dirs (.agent/.agents)" since it lists skills, rules, prompts, commands, and context/system files, never subagents ([#5821](https://github.com/can1357/oh-my-pi/issues/5821)).
### Fixed
- Fixed non-raw `read` line selectors returning context outside the requested inclusive range ([#5802](https://github.com/can1357/oh-my-pi/issues/5802)).
## [17.0.2] - 2026-07-17
+26 -146
View File
@@ -54,7 +54,7 @@ import {
} from "../session/streaming-output";
import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
import { buildLineEntries, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import {
ImageInputTooLargeError,
@@ -164,7 +164,7 @@ const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise<string[] | undefined> {
async function readSmallFileLines(absolutePath: string, fileSize: number): Promise<string[] | undefined> {
if (fileSize > SNAPSHOT_MAX_BYTES) return undefined;
try {
return normalizeToLF(await Bun.file(absolutePath).text()).split("\n");
@@ -395,40 +395,6 @@ function formatSummaryElisionFooter(
}
const READ_CHUNK_SIZE = 8 * 1024;
/**
* Context lines added around an explicit range read. Anchor-stale failures
* cluster on edits whose anchors land just outside the most recent read
* window, but the data (`scripts/session-stats/analyze_selector_reads.py`)
* shows most follow-up reads are disjoint hops, not adjacent extensions —
* so symmetric padding rarely pays for itself.
*
* Leading=1 catches accidental single-line reads where the anchor is the
* line immediately above the requested start. Trailing=3 buffers the
* common case where the agent asks for a narrow range and then needs the
* next few lines to disambiguate an anchor.
*/
const RANGE_LEADING_CONTEXT_LINES = 1;
const RANGE_TRAILING_CONTEXT_LINES = 3;
/**
* Expand a [start, end) range with leading/trailing context lines on the
* sides where the user actually constrained the range. A start of 0 (no
* explicit offset) does not get leading context — that's already an
* open-ended read from the top.
*/
function expandRangeWithContext(
requestedStart: number,
requestedEnd: number,
totalLines: number,
expandStart: boolean,
expandEnd: boolean,
): { startLine: number; endLine: number } {
return {
startLine: expandStart ? Math.max(0, requestedStart - RANGE_LEADING_CONTEXT_LINES) : requestedStart,
endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_TRAILING_CONTEXT_LINES) : requestedEnd,
};
}
async function streamLinesFromFile(
filePath: string,
startLine: number,
@@ -1305,26 +1271,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const details = options.details ?? {};
const allLines = text.split("\n");
const totalLines = allLines.length;
// User-requested 0-indexed range start. Lines BEFORE this are leading
// context (added below if offset is explicit).
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
const startLine = requestedStart;
const ignoreResultLimits = options.ignoreResultLimits ?? false;
const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
// Expand only on sides the user actually constrained: leading context
// when offset>1, trailing context when a finite limit was set. Raw mode
// never expands — without line numbers the padding is indistinguishable
// from requested content, so `raw:31-31` must return line 31 and nothing
// else (verbatim-extraction contract).
const rawDisplay = options.raw === true;
const expanded = expandRangeWithContext(
requestedStart,
requestedEnd,
allLines.length,
!rawDisplay && offset !== undefined && offset > 1,
!rawDisplay && limit !== undefined,
);
const startLine = expanded.startLine;
const endLineExpanded = expanded.endLine;
const endLine = limit !== undefined ? Math.min(startLine + limit, allLines.length) : allLines.length;
const startLineDisplay = startLine + 1;
const resultBuilder = toolResult(details);
@@ -1350,7 +1300,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
.done();
}
const endLine = endLineExpanded;
const selectedContent = allLines.slice(startLine, endLine).join("\n");
const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
@@ -1395,10 +1344,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
emittedHashlineHeader = true;
return prependHashlineHeader(formatted, hashContext);
};
const buildLineEntries = (endLineDisplay: number): LineEntry[] =>
buildLineEntriesWithBlockContext(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], {
path: options.sourcePath,
});
const buildSelectedLineEntries = (endLineDisplay: number): LineEntry[] =>
buildLineEntries(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }]);
let outputText: string;
let truncationInfo:
@@ -1436,7 +1383,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines);
outputText = formatText(truncation.content, startLineDisplay);
} else {
outputText = formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
outputText = formatLineEntries(buildSelectedLineEntries(endLineDisplay), startLineDisplay);
}
details.truncation = truncation;
truncationInfo = {
@@ -1451,7 +1398,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines);
outputText = formatText(selectedContent, startLineDisplay);
} else {
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
outputText = formatLineEntries(buildSelectedLineEntries(endLine), startLineDisplay);
}
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
} else {
@@ -1459,7 +1406,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine);
outputText = formatText(truncation.content, startLineDisplay);
} else {
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
outputText = formatLineEntries(buildSelectedLineEntries(endLine), startLineDisplay);
}
}
@@ -1480,8 +1427,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
* Render a multi-range read against in-memory text. Each range emits a
* formatted block with its own anchors / line numbers, blocks are joined
* with an elision separator, and ranges past EOF surface as `[…]` notices
* so the model can correct the next call. No leading/trailing context is
* added — multi-range callers always specify exact bounds.
* so the model can correct the next call. Context is never added because
* multi-range callers always specify exact bounds.
*/
#buildInMemoryMultiRangeResult(
text: string,
@@ -1538,7 +1485,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
if (options.raw === true) {
outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : "";
} else if (visibleSpans.length > 0) {
const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath });
const entries = buildLineEntries(allLines, visibleSpans);
if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
const firstLine = entries.find(entry => entry.kind === "line");
if (firstLine?.kind === "line") {
@@ -1622,7 +1569,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const notices: string[] = [];
const visibleSpans: Array<{ startLine: number; endLine: number }> = [];
const displayLineByNumber = new Map<number, string>();
const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize);
const fullLines = rawSelector ? undefined : await readSmallFileLines(absolutePath, fileSize);
let columnTruncated = 0;
let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
@@ -1690,23 +1637,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
let outputText: string;
if (!rawSelector && fullLines && visibleSpans.length > 0) {
const entries = buildLineEntriesWithBlockContext(
fullLines,
visibleSpans,
{ path: absolutePath },
{
lineText: (lineNumber, sourceText) => {
const visibleText = displayLineByNumber.get(lineNumber);
if (visibleText !== undefined) return visibleText;
if (maxColumns <= 0) return sourceText;
const truncated = truncateLine(sourceText, maxColumns);
if (truncated.wasTruncated) {
columnTruncated = maxColumns;
}
return truncated.text;
},
},
);
const entries = buildLineEntries(fullLines, visibleSpans, {
lineText: (lineNumber, sourceText) => displayLineByNumber.get(lineNumber) ?? sourceText,
});
const firstLine = entries.find(entry => entry.kind === "line");
displayContent = {
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
@@ -2513,7 +2446,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
// Raw text or line-range mode
const { offset, limit } = selToOffsetLimit(parsed);
// Try ACP bridge first — editor's in-memory buffer is source of truth.
// Request full text so local range rendering keeps normal context and line numbers.
// Request full text so local range rendering preserves exact bounds and line numbers.
const bridgePromise = this.#routeReadThroughBridge(absolutePath);
if (bridgePromise !== undefined) {
try {
@@ -2535,24 +2468,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
}
}
// User-requested 0-indexed range start. Lines BEFORE this become
// leading context (added below if offset is explicit). Raw mode
// never adds context: without line numbers the padding is
// indistinguishable from requested content, so `raw:31-31` must
// return line 31 and nothing else.
const rawSelector = isRawSelector(parsed);
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
const expandStart = !rawSelector && offset !== undefined && offset > 1;
const expandEnd = !rawSelector && limit !== undefined;
const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
const startLine = requestedStart - leadingContext;
const startLine = requestedStart;
const startLineDisplay = startLine + 1;
const DEFAULT_LIMIT = this.#defaultLimit;
const effectiveLimit = limit ?? DEFAULT_LIMIT;
const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
const selectedLineLimit = effectiveLimit;
// Scale byte budget with line limit so the configured line count actually fits.
// Assume ~512 bytes/line average; never go below the shared default.
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
@@ -2614,15 +2538,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
if (cloned) displayLines = cloned;
}
const displayLineByNumber = new Map<number, string>();
for (let i = 0; i < displayLines.length; i++) {
displayLineByNumber.set(startLineDisplay + i, displayLines[i] ?? "");
}
const bracketContextFullLines = rawSelector
? undefined
: await readBracketContextFullLines(absolutePath, fileSize);
const displayedEndLine = startLineDisplay + Math.max(0, displayLines.length - 1);
const selectedContent = displayLines.join("\n");
const userLimitedLines = collectedLines.length;
@@ -2679,36 +2594,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
emittedHashlineHeader = true;
return prependHashlineHeader(formatted, hashContext);
};
const formatBracketAwareText = (): string | undefined => {
if (!bracketContextFullLines) return undefined;
const entries = buildLineEntriesWithBlockContext(
bracketContextFullLines,
[{ startLine: startLineDisplay, endLine: displayedEndLine }],
{ path: absolutePath },
{
lineText: (lineNumber, sourceText) => {
const visibleText = displayLineByNumber.get(lineNumber);
if (visibleText !== undefined) return visibleText;
if (maxColumns <= 0) return sourceText;
const truncated = truncateLine(sourceText, maxColumns);
if (truncated.wasTruncated) {
columnTruncated = maxColumns;
}
return truncated.text;
},
},
);
const firstLine = entries.find(entry => entry.kind === "line");
capturedDisplayContent = {
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startLineDisplay,
lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
};
const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
if (!hashContext || emittedHashlineHeader) return formatted;
emittedHashlineHeader = true;
return prependHashlineHeader(formatted, hashContext);
};
let outputText: string;
@@ -2739,7 +2624,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
},
};
} else if (truncation.truncated) {
outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
outputText = formatText(truncation.content, startLineDisplay);
details = { truncation };
sourcePath = absolutePath;
truncationInfo = {
@@ -2753,7 +2638,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
} else if (startLine + userLimitedLines < totalFileLines || !reachedEof) {
const nextOffset = startLine + userLimitedLines + 1;
outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
outputText = formatText(truncation.content, startLineDisplay);
outputText += reachedEof
? `\n\n[${totalFileLines - (startLine + userLimitedLines)} more lines in file. Use :${nextOffset} to continue]`
: `\n\n[More lines in file (${formatBytes(fileSize)} total; not scanned to EOF). Use :${nextOffset} to continue]`;
@@ -2761,7 +2646,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
sourcePath = absolutePath;
} else {
// No truncation, no user limit exceeded
outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
outputText = formatText(truncation.content, startLineDisplay);
details = {};
sourcePath = absolutePath;
}
@@ -2981,16 +2866,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const { offset, limit } = selToOffsetLimit(parsedSel);
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
// Raw mode never adds context lines — see the plain-file range path.
const expandStart = !rawSelector && offset !== undefined && offset > 1;
const expandEnd = !rawSelector && limit !== undefined;
const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
const startLine = requestedStart - leadingContext;
const startLine = requestedStart;
const startLineDisplay = startLine + 1;
const effectiveLimit = limit ?? this.#defaultLimit;
const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
const selectedLineLimit = effectiveLimit;
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
const streamResult = await streamLinesFromFile(
artifact.path,
@@ -266,27 +266,24 @@ export function findBlockContextLines(
return nativeBlockContext(fullLines, visible, source) ?? lexicalBracketContext(fullLines, visible);
}
/**
* Build display entries for `visibleSpans` plus any off-window block-boundary
* lines, in source order, with `{ kind: "ellipsis" }` markers inserted across
* non-contiguous gaps. `options.lineText` lets callers substitute display text
* (e.g. column-truncated lines) for a given line number.
*/
export function buildLineEntriesWithBlockContext(
fullLines: readonly string[],
visibleSpans: readonly LineSpan[],
source: BlockContextSource = {},
options: {
lineText?: (lineNumber: number, sourceText: string, context: boolean) => string;
} = {},
): LineEntry[] {
const spans = normalizeLineSpans(visibleSpans, fullLines.length);
const visible = visibleLineNumbers(spans);
const context = findBlockContextLines(fullLines, visible, source);
const allLines = new Set<number>(visible);
for (const lineNumber of context.keys()) allLines.add(lineNumber);
interface LineEntryOptions {
lineText?: (lineNumber: number, sourceText: string, context: boolean) => string;
}
function buildEntries(
fullLines: readonly string[],
visible: ReadonlySet<number>,
context: ReadonlyMap<number, string> | undefined,
options: LineEntryOptions,
): LineEntry[] {
const sorted = [...visible];
if (context) {
for (const lineNumber of context.keys()) {
if (!visible.has(lineNumber)) sorted.push(lineNumber);
}
}
sorted.sort((left, right) => left - right);
const sorted = [...allLines].sort((left, right) => left - right);
const entries: LineEntry[] = [];
let previousLine: number | undefined;
for (const lineNumber of sorted) {
@@ -294,7 +291,7 @@ export function buildLineEntriesWithBlockContext(
entries.push({ kind: "ellipsis" });
}
const sourceText = fullLines[lineNumber - 1] ?? "";
const isContext = context.has(lineNumber);
const isContext = context?.has(lineNumber) === true;
entries.push({
kind: "line",
lineNumber,
@@ -307,6 +304,33 @@ export function buildLineEntriesWithBlockContext(
return entries;
}
/** Build display entries for exactly the requested spans, separated by ellipses. */
export function buildLineEntries(
fullLines: readonly string[],
visibleSpans: readonly LineSpan[],
options: LineEntryOptions = {},
): LineEntry[] {
const spans = normalizeLineSpans(visibleSpans, fullLines.length);
return buildEntries(fullLines, visibleLineNumbers(spans), undefined, options);
}
/**
* Build display entries for `visibleSpans` plus any off-window block-boundary
* lines, in source order, with `{ kind: "ellipsis" }` markers inserted across
* non-contiguous gaps. `options.lineText` lets callers substitute display text
* (e.g. column-truncated lines) for a given line number.
*/
export function buildLineEntriesWithBlockContext(
fullLines: readonly string[],
visibleSpans: readonly LineSpan[],
source: BlockContextSource = {},
options: LineEntryOptions = {},
): LineEntry[] {
const spans = normalizeLineSpans(visibleSpans, fullLines.length);
const visible = visibleLineNumbers(spans);
return buildEntries(fullLines, visible, findBlockContextLines(fullLines, visible, source), options);
}
export function lineEntriesToPlainText(entries: readonly LineEntry[], ellipsis = "…"): string {
return entries.map(entry => (entry.kind === "ellipsis" ? ellipsis : entry.text)).join("\n");
}
@@ -79,6 +79,8 @@ describe("read tool multi-range selector", () => {
expect(text).toContain("line 20");
expect(text).toContain("line 21");
expect(text).toContain("line 22");
expect(text).not.toMatch(/^2:line 2$/m);
expect(text).not.toMatch(/^6:line 6$/m);
// Lines between the ranges must be elided
expect(text).not.toContain("line 10");
expect(text).not.toContain("line 19");
@@ -86,7 +88,7 @@ describe("read tool multi-range selector", () => {
expect(text).toContain("…");
});
it("includes the matching closing bracket line outside a forward range", async () => {
it("does not add a closing bracket outside a forward range", async () => {
const filePath = path.join(tmpDir, "brackets.ts");
await fs.writeFile(
filePath,
@@ -106,13 +108,13 @@ describe("read tool multi-range selector", () => {
const text = textOutput(await tool.execute("call-bracket-close", { path: `${filePath}:1-1` }));
expect(text).toContain("function outer() {");
expect(text).toContain("…");
expect(text).toContain("}");
expect(text).not.toContain("…");
expect(text).not.toMatch(/^7:}$/m);
expect(text).not.toContain("const four");
expect(text).not.toContain("return one + two");
});
it("includes the matching opening bracket line outside a reverse range", async () => {
it("does not add an opening bracket outside a reverse range", async () => {
const filePath = path.join(tmpDir, "brackets.ts");
await fs.writeFile(
filePath,
@@ -131,13 +133,14 @@ describe("read tool multi-range selector", () => {
const tool = new ReadTool(createSession(tmpDir));
const text = textOutput(await tool.execute("call-bracket-open", { path: `${filePath}:7-7` }));
expect(text.indexOf("function outer() {")).toBeLessThan(text.indexOf("}"));
expect(text).toContain("…");
expect(text).toMatch(/^7:}$/m);
expect(text).not.toContain("function outer() {");
expect(text).not.toContain("…");
expect(text).not.toContain("const one = 1");
expect(text).not.toContain("const four = 4");
});
it("uses tree-sitter syntactic spans for indentation languages (Python)", async () => {
it("does not add Python syntactic boundaries outside a range", async () => {
const filePath = path.join(tmpDir, "module.py");
await fs.writeFile(
filePath,
@@ -156,15 +159,11 @@ describe("read tool multi-range selector", () => {
);
const tool = new ReadTool(createSession(tmpDir));
// Read only the `def` header (expands by a few trailing context lines).
// Python has no closing delimiter, so a bracket scan would surface
// nothing; tree-sitter surfaces the def's last body line (9) as the
// block boundary, behind an ellipsis for the skipped middle.
const text = textOutput(await tool.execute("call-py-def", { path: `${filePath}:1-1` }));
expect(text).toContain("def greet(name):");
expect(text).toContain("…");
expect(text).toContain("return a + b + c + d + e + f + g + len(name)");
expect(text).not.toContain("…");
expect(text).not.toContain("return a + b + c + d + e + f + g + len(name)");
expect(text).not.toContain("trailing = 1");
});
@@ -179,7 +178,7 @@ describe("read tool multi-range selector", () => {
// All lines from the merged range present
for (const i of [3, 4, 5, 6, 7, 8, 9]) {
expect(text).toContain(`line ${i}\n`);
expect(text).toMatch(new RegExp(`^${i}:line ${i}$`, "m"));
}
// No separator because ranges merged into one contiguous block
expect(text).not.toContain("…");
@@ -77,6 +77,7 @@ describe("read tool large artifact handling", () => {
expect(output).toContain("line-001");
expect(output).toContain("line-003");
expect(output).not.toContain("line-004");
expect(output).toContain("Artifact storage:");
expect(output).toContain("artifact://0:raw:N-M");
expect(output).not.toContain("line-400");
@@ -137,6 +137,7 @@ describe("read PDF with a line-range selector", () => {
.join("\n");
expect(selectorText).toContain("pdf line 2");
expect(selectorText).toContain("pdf line 3");
expect(selectorText).not.toContain("pdf line 1");
expect(convert).toHaveBeenCalledTimes(1);
} finally {
@@ -59,14 +59,12 @@ describe("read tool raw range exactness", () => {
expect(output.trimEnd()).toBe("L01\nL02");
});
it("keeps context padding for numbered range reads", async () => {
// Numbered mode intentionally pads (leading anchor buffer + trailing
// disambiguation lines) — line numbers make the padding self-describing.
it("returns exactly the requested numbered range without context padding", async () => {
const result = await tool.execute("call-numbered", { path: `${filePath}:31-31` });
const output = getTextOutput(result);
expect(output).toContain("L31");
expect(output).toContain("L30");
expect(output).toContain("L32");
expect(output).not.toContain("L30");
expect(output).not.toContain("L32");
});
});