fix(cursor): read pi_ls's entry cap from the shape read actually sets

The bridge looked for a flat `details.resultLimitReached`. `glob` sets
that alongside the structured meta, so `pi_find` worked; `read` - the
tool serving `pi_ls` - records the cap only through `OutputMeta`, at
`details.meta.limits.resultLimit.reached`. Every capped listing
therefore reached Cursor with `entry_limit_reached` unset, which reads
as a complete listing.

Both shapes are now checked, mirroring how `piTruncation` already
handles its two producers. Covered by running the real `ReadTool`
against a directory that trips the per-directory cap and asserting the
wire field, so a move in the producer's shape fails here instead of
silently sending clipped output as whole.

(cherry picked from commit cbfe7ca9d59faa2f967e2724744b58a96a9a1839)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 13:39:47 -03:00
committed by can1357
parent 0601ee7324
commit 6d42f6e52e
3 changed files with 64 additions and 4 deletions
@@ -107,9 +107,29 @@ function bagValue(bag: unknown, key: string): unknown {
*/
function detailCount(toolResult: ToolResultMessage, key: string): number | undefined {
const value = bagValue(toolResult.details, key);
return positiveCount(value);
}
function positiveCount(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
}
/**
* The entry cap a listing hit, from either shape a local tool records it in.
*
* `glob` sets a flat `details.resultLimitReached` alongside the structured
* meta; `read` — which serves `pi_ls` — records the cap only through
* `OutputMeta` at `details.meta.limits.resultLimit.reached`. Reading just the
* flat field dropped `entry_limit_reached` for every real listing, so Cursor
* received clipped output with no incompleteness signal.
*/
function resultLimitReached(toolResult: ToolResultMessage): number | undefined {
const flat = detailCount(toolResult, "resultLimitReached");
if (flat !== undefined) return flat;
const limits = bagValue(bagValue(toolResult.details, "meta"), "limits");
return positiveCount(bagValue(bagValue(limits, "resultLimit"), "reached"));
}
/**
* Translate a local tool's truncation summary into `PiTruncation`.
*
@@ -264,7 +284,7 @@ export function buildPiFindResult(toolResult: ToolResultMessage): PiFindExecResu
value: create(PiFindExecSuccessSchema, {
output: text,
truncation: piTruncation(toolResult),
resultLimitReached: detailCount(toolResult, "resultLimitReached"),
resultLimitReached: resultLimitReached(toolResult),
}),
},
});
@@ -285,7 +305,7 @@ export function buildPiLsResult(toolResult: ToolResultMessage): PiLsExecResult {
value: create(PiLsExecSuccessSchema, {
output: text,
truncation: piTruncation(toolResult),
entryLimitReached: detailCount(toolResult, "resultLimitReached"),
entryLimitReached: resultLimitReached(toolResult),
}),
},
});
+1
View File
@@ -145,6 +145,7 @@
- Fixed Cursor advisor bridge tools bypassing approval settings. The advisor's `pi_edit`/`pi_grep` instances are approval-wrapped, but the wrapper reads `tools.approvalMode`, per-tool `tools.approval.<tool>` policies and `autoApprove` only from the execute-time tool context — which the advisor bridge never supplied, so every native advisor frame resolved as `yolo` with empty policies and ran past a configured `ask` or `deny`. Advisors now receive the same context store as the primary bridge.
- Fixed Cursor's `list_mcp_resources`/`read_mcp_resource` frames answering as though the client hosted no MCP servers. The bridge hardcoded an empty catalog and `not_found`, so resources from servers the session held live connections to were invisible to the model even while the same session read them through `mcp://`. Both frames now answer from the session's `MCPManager`; a lookup failure surfaces as an error rather than an empty catalog, which would read as "asked, none exist".
- Fixed the Cursor native `delete` frame bypassing approval settings. Unlike every other frame it removes the file directly instead of running a registry tool, so no approval wrapper sat in front of it — `allowNativeDelete` answers whether a mutating tool was granted, which is a different question from whether the user's policy allows the call. A configured `tools.approval.delete: deny`, or an `always-ask` session that this channel cannot prompt in, now refuses the frame and keeps the file.
- Fixed `pi_ls` never reporting that a listing was clipped. The bridge read the entry cap from a flat `details.resultLimitReached`, which `glob` sets but `read` — the tool serving `pi_ls` — does not: it records the cap through `OutputMeta` at `details.meta.limits.resultLimit.reached`. Every capped listing therefore reached Cursor with `entry_limit_reached` unset, reading as complete. Both shapes are now checked, the same way the truncation translation already handles its two producers.
## [17.1.5] - 2026-07-27
+41 -2
View File
@@ -5,7 +5,7 @@ import * as path from "node:path";
import { create, fromBinary } from "@bufbuild/protobuf";
import type { AgentEvent, AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { type BlockState, handleServerMessage, type ToolCallState } from "@oh-my-pi/pi-ai/providers/cursor";
import { piTruncation } from "@oh-my-pi/pi-ai/providers/cursor/exec-modern";
import { buildPiLsResult, piTruncation } from "@oh-my-pi/pi-ai/providers/cursor/exec-modern";
import type { AssistantMessage } from "@oh-my-pi/pi-ai/types";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import {
@@ -27,7 +27,7 @@ import {
import { EditTool } from "@oh-my-pi/pi-coding-agent/edit";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { ExtensionToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { GrepTool, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { GrepTool, ReadTool, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash";
import type { TruncationMeta } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
@@ -210,6 +210,45 @@ describe("pi_bash truncation reaches the wire from a real BashTool result", () =
expect(wire?.truncatedBy).toBe(details.meta?.truncation?.truncatedBy);
});
it("reports the entry cap ReadTool actually records for a large listing", async () => {
// Same producer/consumer contract for the listing cap. `glob` records it
// twice — a flat `details.resultLimitReached` and the structured meta —
// but `read`, which serves `pi_ls`, records it only through `OutputMeta`.
// Reading just the flat field dropped `entry_limit_reached` for every
// real listing, so Cursor got clipped output with no signal it was cut.
//
// The root listing is uncapped; the depth-2 tree caps each child
// directory, so the entries have to sit one level down to trip it.
const listing = path.join(cwd, "many");
const child = path.join(listing, "child");
await fs.mkdir(child, { recursive: true });
await Promise.all(Array.from({ length: 40 }, (_, i) => Bun.write(path.join(child, `f${i}.txt`), "x")));
const read = new ReadTool(createTestSession(cwd));
const result = await read.execute("l1", { path: listing });
// Guard the assumption the bridge encodes: the cap lives in the nested
// meta and nowhere flat. If the producer's shape moves, this fails here
// rather than silently sending a clipped listing as if it were whole.
const details = result.details as {
resultLimitReached?: number;
meta?: { limits?: { resultLimit?: { reached: number } } };
};
expect(details.resultLimitReached).toBeUndefined();
expect(details.meta?.limits?.resultLimit?.reached).toBeGreaterThan(0);
const wire = buildPiLsResult({
role: "toolResult",
toolCallId: "l1",
toolName: "read",
content: result.content,
isError: false,
timestamp: Date.now(),
details: result.details,
});
if (wire.result.case !== "success") throw new Error(`expected success, got ${wire.result.case}`);
expect(wire.result.value.entryLimitReached).toBeGreaterThan(0);
});
it("sends no truncation summary for output that fit", async () => {
const bash = new BashTool(createTestSession(cwd));
const result = await bash.execute("t2", { command: "echo hi" });