From 9be8d797fc9baf0d7de9a7863c04a7fd8690c130 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 04:13:35 +0000 Subject: [PATCH] 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 --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/src/providers/cursor-pi-args.ts | 30 +++++++++++++++++++ packages/ai/src/providers/cursor.ts | 25 ++++++++++------ .../ai/src/providers/cursor/exec-modern.ts | 1 + packages/ai/test/cursor-exec-modern.test.ts | 25 ++++++++++++++++ packages/coding-agent/CHANGELOG.md | 4 +++ packages/coding-agent/src/tools/read.ts | 5 ++++ .../test/tools/read-raw-range.test.ts | 6 ++++ 8 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3f4da9b40..2da5bc87f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/providers/cursor-pi-args.ts b/packages/ai/src/providers/cursor-pi-args.ts index d7b759f5a..c88275b8f 100644 --- a/packages/ai/src/providers/cursor-pi-args.ts +++ b/packages/ai/src/providers/cursor-pi-args.ts @@ -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. diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f1c466041..d276a27b0 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -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", diff --git a/packages/ai/src/providers/cursor/exec-modern.ts b/packages/ai/src/providers/cursor/exec-modern.ts index ebf1bccdf..621f00a6e 100644 --- a/packages/ai/src/providers/cursor/exec-modern.ts +++ b/packages/ai/src/providers/cursor/exec-modern.ts @@ -81,6 +81,7 @@ export { piLsPath, piReadDisplayPath, piReadPath, + piReadPathHasRange, piTimeout, } from "../cursor-pi-args"; diff --git a/packages/ai/test/cursor-exec-modern.test.ts b/packages/ai/test/cursor-exec-modern.test.ts index 45947f29e..1a796958e 100644 --- a/packages/ai/test/cursor-exec-modern.test.ts +++ b/packages/ai/test/cursor-exec-modern.test.ts @@ -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 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..195fa975f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index c2d98dbff..454aa828d 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -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 { 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 { 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 { details = {}; sourcePath = absolutePath; } + if (reachedEof) details.totalLines = totalFileLines; if (hashContext?.tag) { recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText); diff --git a/packages/coding-agent/test/tools/read-raw-range.test.ts b/packages/coding-agent/test/tools/read-raw-range.test.ts index 75d5c3cc9..10f4b292b 100644 --- a/packages/coding-agent/test/tools/read-raw-range.test.ts +++ b/packages/coding-agent/test/tools/read-raw-range.test.ts @@ -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.