From ab7b457af066b25fa7dcf6d2fced51d3d7e12174 Mon Sep 17 00:00:00 2001 From: Diogo Soares Rodrigues Date: Mon, 27 Jul 2026 10:07:10 -0300 Subject: [PATCH] fix(cursor): read pi_bash truncation from the shape BashTool emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two truncation records exist locally. `read`/`grep` set `details.truncation` (`TruncationResult`), which carries an explicit `truncated` boolean. `bash` sets `details.meta.truncation` (`TruncationMeta`), which has no such flag — its presence is the signal. `piTruncation` read only the first and required the boolean, so every real Bash truncation was dropped: Cursor got clipped output with no indication it was clipped. Both shapes now translate; `TruncationResult` stays authoritative when present so an explicit `false` still suppresses. Also drops the legacy pi shim's copies of the regex-literal escaper and the path/glob join. Both were verbatim duplicates of the modern bridge's helpers, which is the drift the shared translation exists to prevent. Verified producer-to-consumer, not against a hand-built bag: the test runs a real `BashTool`, asserts its output has no top-level `truncation` and no `truncated` flag under `meta`, then feeds those exact details to `piTruncation`. Typed against the producer's own `TruncationMeta`, so a renamed field fails compilation rather than silently reverting the bug. Mutation-checked: reverting to the top-level lookup, restoring the flag requirement, or dropping the null guard each fails a test. (cherry picked from commit 6699672d52061b832677dd45315f4aba8d330db1) --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/cursor/exec-modern.ts | 25 +++++-- packages/ai/test/cursor-exec-modern.test.ts | 38 +++++++++++ packages/coding-agent/CHANGELOG.md | 1 + .../legacy-pi-coding-agent-shim.ts | 17 ++--- .../coding-agent/test/cursor-exec.test.ts | 66 +++++++++++++++++++ 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 23e8f2717..f9c3ddf66 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -77,6 +77,7 @@ - Fixed the Cursor stream's end-of-transport cleanup erasing the arguments of every block still open. Blocks whose args arrive whole (todo, connect-SCM, MCP) never feed the streamed partial-JSON buffer, and reparsing an absent buffer yields `{}`, so a truncated or disconnected turn rebuilt those calls with no arguments at all. Only blocks that actually streamed their args are reparsed now. - Fixed the Pi exec frames displaying a different operation than the one they run. The provider synthesized its transcript block from a second, hand-rolled translation of the frame args, so `pi_read`'s `offset`/`limit` were shown as a whole-file read, `pi_grep`'s `literal` pattern as an unescaped regex, and `pi_find`'s path/glob join differed from the executed one. Both sides now share a single translation. - Fixed the streamed `pi_*_tool_call` announcements that modern builds send alongside each exec frame being unrecognized. The exec channel already synthesizes those blocks when it runs the tool; the duplicate was avoided only because the decoder recognized none of the variants, which would have started double-rendering as soon as any one was added. +- Fixed `pi_bash` results reaching Cursor clipped with no truncation notice. Two truncation records exist locally: `read`/`grep` set `details.truncation`, which carries an explicit `truncated` flag, while `bash` sets `details.meta.truncation`, whose record has no such flag — its presence is the signal. `piTruncation` read only the first shape and required the flag, so every real Bash truncation was dropped and the server was told the clipped output was complete. Both shapes now translate, and an explicit `truncated: false` still suppresses the field. ## [17.1.4] - 2026-07-26 diff --git a/packages/ai/src/providers/cursor/exec-modern.ts b/packages/ai/src/providers/cursor/exec-modern.ts index d5c559fef..3d2d2f2c4 100644 --- a/packages/ai/src/providers/cursor/exec-modern.ts +++ b/packages/ai/src/providers/cursor/exec-modern.ts @@ -104,8 +104,8 @@ export function piReadPath(path: string, offset?: number, limit?: number): strin * standing alone rather than building a `./`- or `//`-prefixed spec. * * Uses `node:path` rather than string surgery so Windows absolutes (`C:\…`, - * UNC) are recognised and separators stay normalized — the same treatment - * `joinLegacyGlob` gives the legacy pi shim's identical path/glob pair. + * UNC) are recognised and separators stay normalized. The legacy pi shim's + * identical path/glob pair calls this too, so both stay in step. */ export function piJoinPath(basePath: string | undefined, pattern: string): string { if (path.isAbsolute(pattern)) return pattern; @@ -169,17 +169,28 @@ function detailCount(toolResult: ToolResultMessage, key: string): number | undef } /** - * Translate a local tool's truncation summary - * (`coding-agent/src/session/streaming-output.ts:TruncationResult`) into - * `PiTruncation`. + * Translate a local tool's truncation summary into `PiTruncation`. + * + * Two shapes reach here. `read`/`grep` set `details.truncation` + * (`TruncationResult`), which carries an explicit `truncated` boolean. `bash` + * sets `details.meta.truncation` (`TruncationMeta`), which has **no** such + * flag — its presence *is* the signal, and requiring the boolean silently + * dropped every Bash truncation, handing Cursor clipped output with no notice + * that it was clipped. * * Returns `undefined` when nothing was truncated: the field is `optional` on * every Pi success message, and emitting a zeroed `PiTruncation` would tell the * server the output was trimmed to nothing. */ export function piTruncation(toolResult: ToolResultMessage): PiTruncation | undefined { - const truncation = bagValue(toolResult.details, "truncation"); - if (bagValue(truncation, "truncated") !== true) return undefined; + const direct = bagValue(toolResult.details, "truncation"); + // `TruncationResult` is authoritative when present and explicitly false. + const truncation = direct !== undefined ? direct : bagValue(bagValue(toolResult.details, "meta"), "truncation"); + if (truncation === undefined || truncation === null) return undefined; + // `TruncationResult` gates on its flag; `TruncationMeta` has none and is + // only ever attached when output was actually trimmed. + const flag = bagValue(truncation, "truncated"); + if (flag !== undefined && flag !== true) return undefined; const truncatedBy = bagValue(truncation, "truncatedBy"); const totalLines = bagValue(truncation, "totalLines"); const outputLines = bagValue(truncation, "outputLines"); diff --git a/packages/ai/test/cursor-exec-modern.test.ts b/packages/ai/test/cursor-exec-modern.test.ts index 22e33636f..d0b8ef2b4 100644 --- a/packages/ai/test/cursor-exec-modern.test.ts +++ b/packages/ai/test/cursor-exec-modern.test.ts @@ -738,6 +738,44 @@ describe("Cursor modern exec frames: Pi tools", () => { expect(answer.value.result.value.truncation?.totalLines).toBe(5000); }); + it("reads truncation from the `details.meta.truncation` shape real Bash results use", async () => { + // `BashTool` files its summary under `details.meta.truncation` + // (`TruncationMeta`), which carries no `truncated` flag — its presence is + // the signal. Reading only the top-level `TruncationResult` shape handed + // Cursor clipped output with no notice that it was clipped. + const { frames } = await dispatchExec( + buildExecMessage({ case: "piBashArgs", value: create(PiBashExecArgsSchema, { command: "yes" }) }), + { + execHandlers: { + async piBash() { + return toolResult("out", { + details: { + meta: { + truncation: { + direction: "head", + truncatedBy: "bytes", + totalLines: 5000, + totalBytes: 120000, + outputLines: 300, + outputBytes: 4096, + }, + }, + }, + }); + }, + }, + }, + ); + + const answer = soleResult(frames); + if (answer.case !== "piBashResult") throw new Error(`got ${answer.case}`); + if (answer.value.result.case !== "success") throw new Error("expected success"); + expect(answer.value.result.value.truncation?.truncated).toBe(true); + expect(answer.value.result.value.truncation?.truncatedBy).toBe("bytes"); + expect(answer.value.result.value.truncation?.totalLines).toBe(5000); + expect(answer.value.result.value.truncation?.outputBytes).toBe(4096); + }); + it("omits truncation entirely when nothing was truncated", async () => { // `optional PiTruncation` — a zeroed message would claim the output was // trimmed to nothing. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 533faaacf..dbf992d34 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -170,6 +170,7 @@ - Fixed the Cursor Pi exec bridge silently dropping frame arguments. `pi_read`'s `offset`/`limit` were ignored, so a ranged read returned the whole file; `pi_grep`'s `literal` was ignored, so a fixed-string search ran as a regex and matched the wrong lines; and the path/glob join produced a `./`-prefixed spec. Ranges are now composed onto `read`'s `:N+K` inline selector, literal patterns are escaped, and the join uses `node:path`. These are `optional int32` fields, so a present `0` is honored rather than folded into a default: `pi_read` with `limit: 0` answers with empty output instead of the entire file, and `pi_find` with `limit: 0` clamps to 1 the way the reference client does. - `pi_grep`'s `context` and `limit` are honored. Neither is expressible in the model-facing `grep` schema — context width comes from `grep.contextBefore`/`grep.contextAfter` fixed at tool construction — so the bridge builds a per-call `grep` for frames that supply them. `GrepTool` accepts these as constructor options; the model-facing schema is unchanged, and a frame that supplies neither keeps the shared instance and the session's defaults. - `pi_ls`'s `limit` is still not mapped, now deliberately: it caps directory *entries*, while the local `read` tool renders a depth-2 tree with per-directory caps and elision rows and applies a selector as a *rendered line* slice. Mapping it to `:1+K` would cap a different unit while appearing honored. +- The legacy pi shim's regex-literal escaper and path/glob join were verbatim copies of the modern bridge's. Both paths now call the shared helpers, so the two Pi translations cannot drift. ## [17.1.4] - 2026-07-26 diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 44a18115b..b9c5c97b5 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -17,6 +17,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type AuthCredential, SqliteAuthCredentialStore, type TSchema } from "@oh-my-pi/pi-ai"; +import { piEscapeRegexLiteral, piJoinPath } from "@oh-my-pi/pi-ai/providers/cursor/exec-modern"; import { getKeybindings, type Keybinding, Text } from "@oh-my-pi/pi-tui"; import { getAgentDbPath, @@ -298,16 +299,6 @@ function lineRangePath(readPath: string, offset: number | undefined, limit: numb return `${readPath}:${start}-${end}`; } -function escapeRegexLiteral(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function joinLegacyGlob(searchPath: string, pattern: string): string { - if (path.isAbsolute(pattern)) return pattern; - if (!searchPath || searchPath === ".") return pattern; - return path.join(searchPath, pattern); -} - function normalizeLegacyLimit(limit: number | undefined, fallback: number): number { if (limit === undefined || !Number.isFinite(limit)) return fallback; return Math.max(1, Math.floor(limit)); @@ -512,7 +503,7 @@ export function createGrepToolDefinition(cwd: string, options?: GrepToolOptions) renderResult: legacyRenderResult, execute: (toolCallId, params, signal, onUpdate) => { const rawPattern = stringField(params, "pattern") ?? ""; - const pattern = booleanField(params, "literal") ? escapeRegexLiteral(rawPattern) : rawPattern; + const pattern = booleanField(params, "literal") ? piEscapeRegexLiteral(rawPattern) : rawPattern; const searchPath = stringField(params, "path") ?? "."; const glob = stringField(params, "glob"); const context = numberField(params, "context"); @@ -529,7 +520,7 @@ export function createGrepToolDefinition(cwd: string, options?: GrepToolOptions) toolCallId, { pattern, - path: glob ? joinLegacyGlob(searchPath, glob) : searchPath, + path: glob ? piJoinPath(searchPath, glob) : searchPath, case: booleanField(params, "ignoreCase") ? false : undefined, }, signal, @@ -587,7 +578,7 @@ export function createFindToolDefinition(cwd: string, options?: FindToolOptions) } return tool.execute( toolCallId, - { path: joinLegacyGlob(searchPath, pattern), hidden: true, gitignore: true, limit }, + { path: piJoinPath(searchPath, pattern), hidden: true, gitignore: true, limit }, signal, onUpdate, ); diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index a5b248852..8c5ca1362 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -5,6 +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 type { AssistantMessage } from "@oh-my-pi/pi-ai/types"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { @@ -21,6 +22,8 @@ import { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor"; 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 { 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"; import { type } from "arktype"; import { AdviseTool } from "../src/advisor/advise-tool"; @@ -136,6 +139,69 @@ describe("CursorExecHandlers.grep bridge", () => { }); }); +describe("pi_bash truncation reaches the wire from a real BashTool result", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-pibash-trunc-")); + }); + + afterEach(async () => { + await removeWithRetries(cwd); + }); + + it("translates the metadata BashTool actually emits, not a hand-built shape", async () => { + // Producer/consumer contract. `piTruncation` lives in `pi-ai`, which + // cannot import `BashTool`, so every test there must hand-build the + // details bag — and a bag built from the same assumption as the code + // stays green when `BashTool`'s real shape moves. This runs the actual + // tool and feeds its actual output to the actual translator. + const bash = new BashTool(createTestSession(cwd)); + const result = await bash.execute("t1", { command: "seq 1 200000" }); + + // Guard the assumption the bridge encodes: Bash files truncation under + // `details.meta.truncation`, and that record carries no `truncated` + // flag. If either moves, this fails here rather than silently sending + // clipped output to Cursor with no truncation notice. + // `TruncationMeta` is the producer's own type: if a field this bridge + // reads is renamed or dropped, this stops compiling. + const details = result.details as { truncation?: unknown; meta?: { truncation?: TruncationMeta } }; + expect(details.truncation).toBeUndefined(); + expect(details.meta?.truncation).toBeDefined(); + expect(details.meta?.truncation).not.toHaveProperty("truncated"); + + const wire = piTruncation({ + role: "toolResult", + toolCallId: "t1", + toolName: "bash", + content: result.content, + isError: false, + timestamp: Date.now(), + details: result.details, + }); + + expect(wire?.truncated).toBe(true); + expect(wire?.totalLines).toBe(details.meta?.truncation?.totalLines); + expect(wire?.outputBytes).toBe(details.meta?.truncation?.outputBytes); + expect(wire?.truncatedBy).toBe(details.meta?.truncation?.truncatedBy); + }); + + 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" }); + const wire = piTruncation({ + role: "toolResult", + toolCallId: "t2", + toolName: "bash", + content: result.content, + isError: false, + timestamp: Date.now(), + details: result.details, + }); + expect(wire).toBeUndefined(); + }); +}); + describe("CursorExecHandlers error results", () => { const rewrittenErrorTool = (name: string): AgentTool => ({ name,