fix(cursor): corrected inline read range metadata

Detected path-embedded OMP line selectors when reporting Cursor read results and stopped treating ranged payload lengths as whole-file totals.

Exposed exact source line counts from EOF-reaching read results and covered both the wire response and read metadata contracts.

Fixes #7590
This commit is contained in:
roboomp
2026-08-04 04:13:35 +00:00
parent 003bb5548c
commit 9be8d797fc
8 changed files with 91 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Cursor reads with inline OMP range selectors reporting the returned slice length as the source file's `totalLines`, which made sequential reads of an unchanged file appear inconsistent ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)).
## [17.2.7] - 2026-08-03
### Changed
@@ -47,6 +47,36 @@ export function piReadPath(readPath: string, offset?: number, limit?: number): s
return count === undefined ? `${readPath}:raw:${start}-` : `${readPath}:raw:${start}+${count}`;
}
const READ_RANGE_CHUNK_RE = /^L?(\d+)(?:(\.\.|[-+])L?(\d+)?)?$/i;
function isReadRangeList(value: string): boolean {
return value.split(",").every(chunk => {
const match = READ_RANGE_CHUNK_RE.exec(chunk);
if (!match) return false;
const start = Number.parseInt(match[1]!, 10);
if (start < 1) return false;
const separator = match[2];
if (!separator) return true;
const end = match[3] ? Number.parseInt(match[3], 10) : undefined;
if (separator === "+") return end !== undefined && end >= 1;
return end === undefined || end >= start;
});
}
/**
* Whether a read path ends in an OMP line selector, including compound `raw`
* forms. Cursor uses this only to describe the operation already executed by
* the coding-agent read tool; the selector remains embedded in the path.
*/
export function piReadPathHasRange(readPath: string): boolean {
const chunks = readPath.split(":");
const last = chunks.at(-1);
if (last && isReadRangeList(last)) return true;
if (last?.toLowerCase() !== "raw") return false;
const preceding = chunks.at(-2);
return preceding !== undefined && isReadRangeList(preceding);
}
/**
* The same range as {@link piReadPath}, rendered for a transcript block rather
* than for execution.
+16 -9
View File
@@ -216,6 +216,7 @@ import {
piLimit,
piLsPath,
piReadDisplayPath,
piReadPathHasRange,
piTimeout,
} from "./cursor/exec-modern";
@@ -1322,7 +1323,7 @@ async function handleExecServerMessage(
buildReadResultFromToolResult(
args.path,
toolResult,
args.offset !== undefined || args.limit !== undefined,
args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path),
),
reason => buildReadRejectedResult(args.path, reason),
error => buildReadErrorResult(args.path, error),
@@ -2439,15 +2440,21 @@ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): bo
/**
* The file's own line count, when the tool recorded one.
*
* `details.meta.truncation.totalLines` is the whole file; the flat
* `details.truncation.totalLines` counts from the window's start line and is
* deliberately not consulted here. Absent for a read that returned the file
* whole, where the payload IS the file and counting it is exact.
* Read results expose the source-wide count directly when known. Older tool
* results carry it at `details.meta.truncation.totalLines`; the flat
* `details.truncation.totalLines` counts from a window's start and is
* deliberately not consulted here.
*/
function readTotalLinesFromDetails(toolResult: ToolResultMessage): number | undefined {
if (!toolResult.details || typeof toolResult.details !== "object") return undefined;
const meta = (toolResult.details as { meta?: { truncation?: { totalLines?: unknown } } }).meta;
const totalLines = meta?.truncation?.totalLines;
const details = toolResult.details;
if (!details || typeof details !== "object") return undefined;
const direct = "totalLines" in details ? details.totalLines : undefined;
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
const meta = "meta" in details ? details.meta : undefined;
if (!meta || typeof meta !== "object") return undefined;
const truncation = "truncation" in meta ? meta.truncation : undefined;
if (!truncation || typeof truncation !== "object") return undefined;
const totalLines = "totalLines" in truncation ? truncation.totalLines : undefined;
return typeof totalLines === "number" && Number.isFinite(totalLines) ? totalLines : undefined;
}
@@ -2467,7 +2474,7 @@ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessa
// whole file. Under a composed window it is the window's, and answering a
// 20-line page of a 100-line file with `total_lines: 20` tells a paginating
// server it has reached the end.
const totalLines = readTotalLinesFromDetails(toolResult) ?? (text ? text.split("\n").length : 0);
const totalLines = readTotalLinesFromDetails(toolResult) ?? (rangeApplied ? 0 : text ? text.split("\n").length : 0);
return create(ReadResultSchema, {
result: {
case: "success",
@@ -81,6 +81,7 @@ export {
piLsPath,
piReadDisplayPath,
piReadPath,
piReadPathHasRange,
piTimeout,
} from "../cursor-pi-args";
@@ -1680,6 +1680,31 @@ describe("Cursor legacy read frame: range reporting", () => {
if (wholeAnswer.value.result.case !== "success") throw new Error(`got ${wholeAnswer.value.result.case}`);
expect(wholeAnswer.value.result.value.rangeApplied).toBe(false);
});
it("treats a path-embedded selector as ranged without reporting the slice as the file total", async () => {
const slice = Array.from({ length: 55 }, (_, index) => `line ${index + 301}`).join("\n");
const { frames } = await dispatchExec(
buildExecMessage({
case: "readArgs",
value: create(ReadArgsSchema, {
path: "/repo/plan.md:raw:301-",
toolCallId: "c-inline",
}),
}),
{
execHandlers: {
async read() {
return toolResult(slice, { details: { fileSize: 21_015 } });
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "readResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`);
expect(answer.value.result.value.totalLines).toBe(0);
expect(answer.value.result.value.rangeApplied).toBe(true);
expect(answer.value.result.value.fileSize).toBe(21_015n);
});
it("carries the composed selector into the synthesized call", async () => {
// A bare path beside a ranged result makes the slice look like the whole
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Exposed exact source line counts in read results when selector-based reads reach EOF, allowing protocol bridges to distinguish a returned slice from the complete file ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)).
## [17.2.7] - 2026-08-03
### Changed
+5
View File
@@ -737,6 +737,8 @@ export interface ReadToolDetails {
meta?: OutputMeta;
/** Full on-disk byte size recorded before applying a file range. */
fileSize?: number;
/** Full source line count when the read reached EOF and the count is exact. */
totalLines?: number;
/** Raw text + start line for user-visible TUI rendering, set when content is text-like.
* Mirrors the same lines the model receives but without hashline/line-number prefixes,
* so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -1397,6 +1399,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const details = options.details ?? {};
const allLines = text.split("\n");
const totalLines = allLines.length;
details.totalLines = totalLines;
// 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;
@@ -1592,6 +1595,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const details = options.details ?? {};
const allLines = text.split("\n");
const totalLines = allLines.length;
details.totalLines = totalLines;
const shouldAddHashLines = displayMode.hashLines;
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
const hashContext =
@@ -2908,6 +2912,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
details = {};
sourcePath = absolutePath;
}
if (reachedEof) details.totalLines = totalFileLines;
if (hashContext?.tag) {
recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
@@ -59,6 +59,12 @@ describe("read tool raw range exactness", () => {
expect(output.trimEnd()).toBe("L01\nL02");
});
it("records the source line count for an open-ended range that reaches EOF", async () => {
const result = await tool.execute("call-raw-tail", { path: `${filePath}:raw:31-` });
expect(result.details?.totalLines).toBe(60);
});
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.