diff --git a/packages/ai/src/providers/cursor/exec-modern.ts b/packages/ai/src/providers/cursor/exec-modern.ts index b42b15e92..4d0c589c7 100644 --- a/packages/ai/src/providers/cursor/exec-modern.ts +++ b/packages/ai/src/providers/cursor/exec-modern.ts @@ -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), }), }, }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1842ae1e5..1f8e7e740 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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.` 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 diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index 507b9fb20..8376dced4 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -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" });