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:
committed by
can1357
parent
0601ee7324
commit
6d42f6e52e
@@ -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),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user