-
Notifications
You must be signed in to change notification settings - Fork 853
fix(cursor): normalize empty and failure-state Computer Use tool results (#1920) #2038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+273
−7
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /** | ||
| * Cursor tool-result normalization for Computer Use / node_repl surfaces (#1920/#1866). | ||
| * | ||
| * Scoped re-implementation of PR #1920 per the 260818 campaign disposition | ||
| * (REDESIGN-SMALL: "apply formatted.text at native toolResultPart + decode test"). | ||
| * Only empty-output and known-failure-state normalization ships here; screenshot | ||
| * stripping and AXTree text compaction from the original PR are deliberately out | ||
| * of scope (the native path already bounds step size by real serialized bytes, | ||
| * dropping images oldest-first — see toolCallStep in protobuf-request.ts). | ||
| */ | ||
|
|
||
| const COMPUTER_USE_TOOL_NAMES = new Set([ | ||
| "node_repl", | ||
| "node_repl__js", | ||
| "mcp__node_repl__js", | ||
| "get_app_state", | ||
| "list_apps", | ||
| "screenshot", | ||
| "computer_use", | ||
| ]); | ||
|
|
||
| function isNodeReplOrComputerUseTool(toolName?: string, toolNamespace?: string): boolean { | ||
| if (toolNamespace && (toolNamespace === "mcp__node_repl" || toolNamespace.includes("node_repl") || toolNamespace.includes("computer_use"))) { | ||
| return true; | ||
| } | ||
| if (!toolName) return false; | ||
| const lower = toolName.toLowerCase(); | ||
| if (COMPUTER_USE_TOOL_NAMES.has(lower)) return true; | ||
| return lower.startsWith("mcp__node_repl") || lower.startsWith("mcp__computer_use"); | ||
| } | ||
|
|
||
| /** Failure states the Computer Use / node_repl runtime reports as PLAIN TEXT inside a non-error result. */ | ||
| const RUNTIME_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; guidance: string }> = [ | ||
| { | ||
| marker: "SkyComputerUseError", | ||
| guidance: "The Computer Use runtime rejected this action. Re-check application state with get_app_state before retrying.", | ||
| }, | ||
| { | ||
| marker: "sky is not defined", | ||
| guidance: "The sky binding is unavailable in this context; Computer Use calls only work inside the privileged node_repl session.", | ||
| }, | ||
| { | ||
| marker: "has already been declared", | ||
| guidance: "The node_repl session keeps earlier declarations; rename the variable or use var/reassignment instead of redeclaring.", | ||
| }, | ||
| { | ||
| marker: "unsupported import in exec", | ||
| guidance: "Imports are not available in this exec context; use the injected globals instead.", | ||
| }, | ||
| ]; | ||
|
|
||
| /** Matches exec wrappers whose only payload is an empty-output marker. */ | ||
| const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Script failed|Command finished|Execution finished)[^\n]*\n+)?(?:Wall time[^\n]*\n+)?(?:Output:\s*)?(?:<empty>)?\s*$/; | ||
|
|
||
| export interface NormalizedToolResultText { | ||
| text: string; | ||
| isError: boolean; | ||
| /** True when normalization changed either field (lets callers skip work on the common path). */ | ||
| changed: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Normalize a Cursor-bound tool-result TEXT payload: | ||
| * - blank / empty-exec-wrapper output on Computer Use or node_repl tools becomes an | ||
| * actionable error instead of an empty string the model silently accepts; | ||
| * - known runtime failure states reported as plain text are marked isError with a | ||
| * one-line recovery hint appended. | ||
| * Everything else passes through byte-identical. | ||
| */ | ||
| export function normalizeCursorToolResultText( | ||
| text: string, | ||
| options: { toolName?: string; toolNamespace?: string; isError?: boolean } = {}, | ||
| ): NormalizedToolResultText { | ||
| const isError = options.isError === true; | ||
| const computerUse = isNodeReplOrComputerUseTool(options.toolName, options.toolNamespace); | ||
| if (computerUse && EMPTY_EXEC_OUTPUT_REGEX.test(text.trim())) { | ||
| return { | ||
| text: "[empty output: the tool ran but produced no stdout or return value. Verify application state with get_app_state, or make the script emit output.]", | ||
| isError: true, | ||
| changed: true, | ||
| }; | ||
| } | ||
| if (!isError) { | ||
| for (const { marker, guidance } of RUNTIME_FAILURE_GUIDANCE) { | ||
| if (text.includes(marker)) { | ||
| return { text: `${text}\n[recovery: ${guidance}]`, isError: true, changed: true }; | ||
| } | ||
| } | ||
| } | ||
| return { text, isError, changed: false }; | ||
| } | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { create, fromBinary } from "@bufbuild/protobuf"; | ||
| import { handleCursorNativeKv } from "../src/adapters/cursor/native-exec"; | ||
| import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; | ||
| import { normalizeCursorToolResultText } from "../src/adapters/cursor/tool-result-normalize"; | ||
| import { | ||
| AgentClientMessageSchema, | ||
| ConversationTurnStructureSchema, | ||
| ConversationStepSchema, | ||
| GetBlobArgsSchema, | ||
| KvServerMessageSchema, | ||
| } from "../src/adapters/cursor/gen/agent_pb"; | ||
| import type { OcxMessage } from "../src/types"; | ||
|
|
||
| function blobData(blobId: Uint8Array): Uint8Array { | ||
| const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { | ||
| id: 1, | ||
| message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, | ||
| }))); | ||
| if (reply.message.case !== "kvClientMessage") throw new Error("not kv"); | ||
| const kv = reply.message.value; | ||
| if (kv.message.case !== "getBlobResult") throw new Error("not blob result"); | ||
| return kv.message.value.blobData; | ||
| } | ||
|
|
||
| /** Decode the native-wire McpToolResult attached to the first tool call step. */ | ||
| function decodedToolResult(bytes: Uint8Array) { | ||
| const msg = fromBinary(AgentClientMessageSchema, bytes); | ||
| const run = msg.message.case === "runRequest" ? msg.message.value : undefined; | ||
| const turnIds = run?.conversationState?.turns ?? []; | ||
| for (const turnId of turnIds) { | ||
| const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnId)); | ||
| if (turn.turn.case !== "agentConversationTurn") continue; | ||
| for (const stepId of turn.turn.value.steps ?? []) { | ||
| const step = fromBinary(ConversationStepSchema, blobData(stepId)); | ||
| if (step.message.case !== "toolCall") continue; | ||
| const tool = step.message.value.tool; | ||
| if (tool.case !== "mcpToolCall") continue; | ||
| const result = tool.value.result; | ||
| if (result?.result.case !== "success") continue; | ||
| return result.result.value; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function requestWith(resultContent: string, toolOverrides: Partial<{ toolName: string; toolNamespace?: string; isError: boolean }> = {}) { | ||
| const rawMessages: OcxMessage[] = [ | ||
| { role: "user", content: "run it", timestamp: 1 }, | ||
| { | ||
| role: "assistant", | ||
| model: "cursor/auto", | ||
| timestamp: 2, | ||
| content: [{ type: "toolCall", id: "call_1", name: toolOverrides.toolName ?? "js", namespace: toolOverrides.toolNamespace ?? "mcp__node_repl", arguments: {} }], | ||
| }, | ||
| { | ||
| role: "toolResult", | ||
| toolCallId: "call_1", | ||
| toolName: toolOverrides.toolName ?? "js", | ||
| toolNamespace: "toolNamespace" in toolOverrides ? toolOverrides.toolNamespace : "mcp__node_repl", | ||
| content: resultContent, | ||
| isError: toolOverrides.isError ?? false, | ||
| timestamp: 3, | ||
| }, | ||
| ]; | ||
| return encodeCursorRunRequest({ | ||
| modelId: "composer-2.5", | ||
| conversationId: "cursor_normalize_test", | ||
| system: ["You are helpful."], | ||
| messages: [{ role: "tool", content: "[tool_result]" }], | ||
| rawMessages, | ||
| }); | ||
| } | ||
|
|
||
| describe("normalizeCursorToolResultText (#1920/#1866 unit rows)", () => { | ||
| test("blank node_repl output becomes an actionable error", () => { | ||
| const out = normalizeCursorToolResultText("", { toolName: "js", toolNamespace: "mcp__node_repl" }); | ||
| expect(out.isError).toBe(true); | ||
| expect(out.text).toContain("get_app_state"); | ||
| }); | ||
|
|
||
| test("empty exec wrapper (Script completed + <empty>) normalizes for node_repl", () => { | ||
| const out = normalizeCursorToolResultText("Script completed\nOutput:\n<empty>", { toolName: "node_repl" }); | ||
| expect(out.isError).toBe(true); | ||
| expect(out.text).toContain("[empty output"); | ||
| }); | ||
|
|
||
| test.each([ | ||
| ["SkyComputerUseError: focus lost", "get_app_state"], | ||
| ["ReferenceError: sky is not defined", "privileged node_repl"], | ||
| ["SyntaxError: Identifier 'x' has already been declared", "redeclaring"], | ||
| ["unsupported import in exec", "injected globals"], | ||
| ])("runtime failure %p is marked as error with guidance", (payload, hint) => { | ||
| const out = normalizeCursorToolResultText(payload, { toolName: "js", toolNamespace: "mcp__node_repl" }); | ||
| expect(out.isError).toBe(true); | ||
| expect(out.text).toContain(payload); | ||
| expect(out.text).toContain(hint); | ||
| }); | ||
|
|
||
| test("a non-computer-use tool with empty output stays byte-identical", () => { | ||
| const out = normalizeCursorToolResultText("", { toolName: "read_file" }); | ||
| expect(out.changed).toBe(false); | ||
| expect(out.text).toBe(""); | ||
| expect(out.isError).toBe(false); | ||
| }); | ||
|
|
||
| test("ordinary non-empty output on node_repl stays byte-identical", () => { | ||
| const out = normalizeCursorToolResultText("42", { toolName: "js", toolNamespace: "mcp__node_repl" }); | ||
| expect(out.changed).toBe(false); | ||
| expect(out.text).toBe("42"); | ||
| }); | ||
|
|
||
| test("an already-error result is not double-annotated", () => { | ||
| const out = normalizeCursorToolResultText("SkyComputerUseError: x", { toolName: "js", toolNamespace: "mcp__node_repl", isError: true }); | ||
| expect(out.changed).toBe(false); | ||
| expect(out.isError).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe("native wire decode (#1920 disposition: formatted text at toolResultPart)", () => { | ||
| test("an empty node_repl result decodes as normalized error text with isError=true on the wire", () => { | ||
| const result = decodedToolResult(requestWith("")); | ||
| expect(result).toBeDefined(); | ||
| expect(result!.isError).toBe(true); | ||
| const first = result!.content[0]; | ||
| expect(first.content.case).toBe("text"); | ||
| expect(first.content.case === "text" ? first.content.value.text : "").toContain("[empty output"); | ||
| }); | ||
|
|
||
| test("a failure-state node_repl result decodes with recovery guidance and isError=true", () => { | ||
| const result = decodedToolResult(requestWith("ReferenceError: sky is not defined")); | ||
| expect(result).toBeDefined(); | ||
| expect(result!.isError).toBe(true); | ||
| const first = result!.content[0]; | ||
| expect(first.content.case === "text" ? first.content.value.text : "").toContain("recovery"); | ||
| }); | ||
|
|
||
| test("a normal tool result decodes byte-identical (no normalization side effects)", () => { | ||
| const result = decodedToolResult(requestWith("plain output", { toolName: "read_file", toolNamespace: undefined })); | ||
| expect(result).toBeDefined(); | ||
| expect(result!.isError).toBe(false); | ||
| const first = result!.content[0]; | ||
| expect(first.content.case === "text" ? first.content.value.text : "").toBe("plain output"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Normalize text-only content-part results before native serialization.
OcxToolResultMessage.contentpermitsOcxContentPart[]. When that array contains only text,decodeResultParts()returns an array. Lines 433-437 then bypass normalization, and Lines 570-578 retainmessage.isError.A
node_replresult withcontent: [{ type: "text", text: "" }]andisError: falsetherefore reaches the nativeMcpSuccessSchemaas blank non-error output. A text-part result containingReferenceError: sky is not definedalso reaches native Cursor without recovery guidance. Root and fallback replay paths normalizecontentToText(message.content), so the same logical result has different text and error state by encoding path.src/adapters/cursor/protobuf-request.ts#L493-L515: Classify content as normalizable when it is a raw string or an all-text part sequence. Preserve encrypted, image-bearing, and undecodable content.src/adapters/cursor/protobuf-request.ts#L433-L437: Use the shared normalized text for all normalizable content forms.src/adapters/cursor/protobuf-request.ts#L570-L578: Use the same normalizedisErrorvalue for all normalizable content forms.tests/cursor-toolresult-normalize.test.ts#L47-L73: Extend the request helper, or add a helper, to constructOcxContentPart[]tool results.tests/cursor-toolresult-normalize.test.ts#L120-L145: Add native-wire regressions for empty and known-failure text-only content parts.As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.”
📍 Affects 2 files
src/adapters/cursor/protobuf-request.ts#L493-L515(this comment)src/adapters/cursor/protobuf-request.ts#L433-L437src/adapters/cursor/protobuf-request.ts#L570-L578tests/cursor-toolresult-normalize.test.ts#L47-L73tests/cursor-toolresult-normalize.test.ts#L120-L145🤖 Prompt for AI Agents
Source: Path instructions