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
+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" });