diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 7858b5238f..b3f7f34c8c 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -54,6 +54,11 @@ import { CURSOR_SHELL_ALIAS_SYSTEM_NOTE, OCX_RESPONSES_TOOL_PROVIDER, } from "./tool-definitions"; +import { + CURSOR_TRUNCATION_MARKER, + compactComputerUsePayload, + formatToolResultToWireText, +} from "./tool-result-compaction"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -67,6 +72,17 @@ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization"; export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192; /** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */ export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024; +/** + * Per-tool-result share of the root budget for replayed history. The whole-root limit cannot be + * used per result: N results would each be allowed the full budget and force turn pruning (#1866). + */ +export const CURSOR_HISTORY_TOOL_RESULT_BYTE_LIMIT = 32 * 1024; +/** Headroom for the wire header, the [Tool Result] prefix, and the JSON envelope. */ +const CURSOR_TOOL_RESULT_ENVELOPE_HEADROOM = 512; + +function historyToolResultBodyByteLimit(): number { + return Math.max(0, CURSOR_HISTORY_TOOL_RESULT_BYTE_LIMIT - CURSOR_TOOL_RESULT_ENVELOPE_HEADROOM); +} /** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */ function runtimeTimeZone(): string { @@ -125,7 +141,19 @@ function rootBlobCandidate( function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): RootBlobCandidate | null { if (entry.byteLength <= maxBytes) return entry; if (entry.role !== "toolResult" || entry.text === undefined) return null; - const marker = "\n…[truncated for Cursor external replay budget]"; + const marker = CURSOR_TRUNCATION_MARKER; + + // 1. Try structured compaction first for Computer Use / large payloads + const compacted = compactComputerUsePayload(entry.text, maxBytes); + if (compacted !== entry.text) { + const candidate = rootBlobCandidate( + { role: "user", content: [{ type: "text", text: compacted }] }, + "toolResult", + { messageIndex: entry.messageIndex, text: compacted }, + ); + if (candidate.byteLength <= maxBytes) return candidate; + } + const encoded = encoder.encode(entry.text); // Leave headroom for JSON envelope (`role`/`content` wrapper) around the truncated text. let keepBytes = Math.min(encoded.byteLength, Math.max(0, maxBytes - encoder.encode(marker).byteLength - 96)); @@ -229,8 +257,11 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR } // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here. } else if (message.role === "toolResult") { - const prefix = message.isError ? "[Tool Error]" : "[Tool Result]"; - const text = `${prefix}\n${toolResultToText(message)}`; + const { wireOutput, isError } = formatToolResultToWireText(message, { + maxBytes: historyToolResultBodyByteLimit(), + }); + const prefix = isError ? "[Tool Error]" : "[Tool Result]"; + const text = `${prefix}\n${wireOutput}`; entries.push(rootBlobCandidate( { role: "user", content: [{ type: "text", text }] }, "toolResult", @@ -249,8 +280,8 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes); // Retain the active trailing tool-result block when it fits (may truncate text). - // If even a truncation marker cannot fit the remaining budget, omit it rather than - // emitting an oversized root blob. + // If even a truncation marker cannot fit the remaining budget, retain a minimal marker + // rather than dropping the active tool result completely (#1866). let activeStart = history.length; while (activeStart > 0 && history[activeStart - 1]?.role === "toolResult") activeStart -= 1; const active = history @@ -268,8 +299,30 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR active[0] = truncated; activeBytes = truncated.byteLength; } else { - active.length = 0; - activeBytes = 0; + const minimal = rootBlobCandidate( + { role: "user", content: [{ type: "text", text: `[Tool Result]\n${CURSOR_TRUNCATION_MARKER.trimStart()}` }] }, + "toolResult", + { messageIndex: active[0].messageIndex, text: `[Tool Result]\n${CURSOR_TRUNCATION_MARKER.trimStart()}` }, + ); + if (minimal.byteLength <= historyBudget) { + active[0] = minimal; + activeBytes = minimal.byteLength; + } else { + active.length = 0; + activeBytes = 0; + } + } + } else if (active.length === 0 && history.length > activeStart) { + const lastActive = history[history.length - 1]; + if (lastActive) { + const minimal = rootBlobCandidate( + { role: "user", content: [{ type: "text", text: `[Tool Result]\n${CURSOR_TRUNCATION_MARKER.trimStart()}` }] }, + "toolResult", + { messageIndex: lastActive.messageIndex, text: `[Tool Result]\n${CURSOR_TRUNCATION_MARKER.trimStart()}` }, + ); + if (minimal.byteLength <= historyBudget) { + active.push(minimal); + } } } @@ -472,14 +525,7 @@ function toolResultContentItems( } function toolResultToText(message: OcxToolResultMessage): string { - return [ - "[tool_result]", - `call_id: ${message.toolCallId}`, - `name: ${namespacedToolName(message.toolNamespace, message.toolName)}`, - `is_error: ${message.isError}`, - "output:", - contentToText(message.content), - ].join("\n"); + return formatToolResultToWireText(message, { maxBytes: historyToolResultBodyByteLimit() }).wireOutput; } function argBytes(value: unknown): Uint8Array { @@ -535,11 +581,12 @@ function toolCallStep( } function toolResultPart(message: OcxToolResultMessage, decoded?: DecodedResultPart[], maxImages?: number) { + const formatted = formatToolResultToWireText(message, { maxBytes: historyToolResultBodyByteLimit() }); return create(McpToolResultSchema, { result: { case: "success", value: create(McpSuccessSchema, { - isError: message.isError, + isError: formatted.isError, content: toolResultContentItems(message, decoded, maxImages), }), }, @@ -631,12 +678,13 @@ function conversationTurns( } if (message.role === "toolResult") { if (!current) continue; + const formatted = formatToolResultToWireText(message, { maxBytes: historyToolResultBodyByteLimit() }); if (externalModel) { - const prefix = message.isError ? "[Tool Error]" : "[Tool Result]"; + const prefix = formatted.isError ? "[Tool Error]" : "[Tool Result]"; current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { case: "assistantMessage", - value: create(AssistantMessageSchema, { text: `${prefix}\n${contentToText(message.content)}` }), + value: create(AssistantMessageSchema, { text: `${prefix}\n${formatted.wireOutput}` }), }, })), requestScope)); continue; @@ -649,7 +697,7 @@ function conversationTurns( current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { case: "assistantMessage", - value: create(AssistantMessageSchema, { text: toolResultToText(message) }), + value: create(AssistantMessageSchema, { text: formatted.wireOutput }), }, })), requestScope)); } diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index da003f85a0..5d0a14dc48 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -25,6 +25,7 @@ import { isCursorWaitTool, } from "./tool-definitions"; import { lookupCursorThreadConversation } from "./thread-continuity"; +import { formatToolResultToWireText } from "./tool-result-compaction"; /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */ export const CURSOR_TOOL_COUNT_LIMIT = 330; @@ -223,15 +224,9 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri } } +/** Request JSON messages have no external replay byte budget; normalize only, do not cap size. */ function toolResultToText(message: OcxToolResultMessage): string { - return [ - "[tool_result]", - `call_id: ${message.toolCallId}`, - `name: ${namespacedToolName(message.toolNamespace, message.toolName)}`, - `is_error: ${message.isError}`, - "output:", - contentToText(message.content), - ].join("\n"); + return formatToolResultToWireText(message).wireOutput; } function contentToText(content: string | readonly (OcxContentPart | OcxAssistantContentPart)[]): string { diff --git a/src/adapters/cursor/tool-result-compaction.ts b/src/adapters/cursor/tool-result-compaction.ts new file mode 100644 index 0000000000..9f87fff73b --- /dev/null +++ b/src/adapters/cursor/tool-result-compaction.ts @@ -0,0 +1,265 @@ +import type { OcxContentPart, OcxAssistantContentPart, OcxToolResultMessage } from "../../types"; +import { namespacedToolName } from "../../types"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export const CURSOR_TRUNCATION_MARKER = "\n…[truncated for Cursor external replay budget]"; + +const COMPUTER_USE_TOOL_NAMES = new Set([ + "node_repl", + "node_repl__js", + "mcp__node_repl__js", + "get_app_state", + "list_apps", + "screenshot", + "computer_use", + "desktop", +]); + +export function isNodeReplOrComputerUseTool(toolName?: string, toolNamespace?: string): boolean { + if (toolNamespace && (toolNamespace === "mcp__node_repl" || toolNamespace.includes("computer_use") || toolNamespace.includes("node_repl"))) { + return true; + } + if (!toolName) return false; + const lower = toolName.toLowerCase(); + if (COMPUTER_USE_TOOL_NAMES.has(lower)) return true; + if (lower.startsWith("mcp__node_repl") || lower.startsWith("mcp__computer_use")) return true; + return false; +} + +export function detectComputerUsePayload(text: string): boolean { + return ( + text.includes("@oai/sky") + || text.includes("SkyComputerUseError") + || text.includes("get_app_state") + || text.includes("list_apps") + || text.includes("AXTree") + || text.includes("AXUIElement") + || text.includes("The user changed '") + || text.includes("sky is not defined") + || text.includes("unsupported import in exec") + ); +} + +function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined { + switch (part.type) { + case "text": + return part.text; + case "thinking": + return part.thinking; + case "image": + return `[image input unsupported by Cursor adapter phase 3: ${part.detail ?? "auto"}]`; + case "toolCall": + return undefined; + } +} + +export function rawContentToText(content: string | readonly (OcxContentPart | OcxAssistantContentPart)[]): string { + if (typeof content === "string") return content; + return content + .map(contentPartToText) + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join("\n"); +} + +const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Command finished|Execution finished)[^\n]*\n+)?(?:Output:\s*)?\s*$/i; + +/** + * Normalizes tool result content: + * 1. Converts empty/whitespace outer exec output on Computer Use/node_repl tools into an informative error. + * 2. Catches SkyComputerUseError / Chrome state changes, Identifier collision errors, and missing sky bindings, + * marking isError=true and attaching recovery guidance so the model recovers immediately. + */ +export function normalizeToolResultContent( + content: string | readonly (OcxContentPart | OcxAssistantContentPart)[], + toolNamespace?: string, + toolName?: string, + isError = false, +): { text: string; isError: boolean } { + let text = rawContentToText(content); + let effectiveIsError = isError; + const isComputerUseOrRepl = isNodeReplOrComputerUseTool(toolName, toolNamespace) || detectComputerUsePayload(text); + + // Check for empty or outer-exec empty output + const trimmed = text.trim(); + const isBlank = trimmed.length === 0; + const isEmptyOutput = isBlank || EMPTY_EXEC_OUTPUT_REGEX.test(trimmed); + if (isEmptyOutput) { + if (isComputerUseOrRepl || effectiveIsError) { + text = "[empty output: tool executed with no stdout or return value. If this was a Computer Use action or node_repl script, verify application state with get_app_state.]"; + effectiveIsError = true; + } else { + // Keep an explicit `` marker; only blank content collapses to "". + text = isBlank ? "" : trimmed; + } + return { text, isError: effectiveIsError }; + } + + // Check for SkyComputerUseError: app/window focus changed + if (text.includes("The user changed '") || text.includes("SkyComputerUseError")) { + effectiveIsError = true; + if (!text.includes("Re-query the latest state with `get_app_state`")) { + const match = text.match(/The user changed '([^']+)'/); + const cause = match + ? `SkyComputerUseError: The user changed '${match[1]}'. ` + : ""; + text = `${cause}Re-query the latest state with \`get_app_state\` before sending more actions.\n\n${text}`; + } + } + + // Check for node_repl variable re-declaration collision + if (/Identifier '([^']+)' has already been declared/.test(text)) { + effectiveIsError = true; + if (!text.includes("In node_repl, use var or reassign")) { + text = `${text}\n[node_repl note: In node_repl, use var, reassign without let/const, or wrap the snippet in a block scope '{ ... }'.]`; + } + } + + // Check for missing sky binding + if (text.includes("sky is not defined")) { + effectiveIsError = true; + if (!text.includes("import @oai/sky")) { + text = `${text}\n[node_repl note: Computer Use requires importing '@oai/sky' in node_repl: const { sky } = require('@oai/sky');]`; + } + } + + // Check for unsupported import in exec + if (text.includes("unsupported import in exec")) { + effectiveIsError = true; + if (!text.includes("use mcp__node_repl__js")) { + text = `${text}\n[exec note: Outer code-mode exec cannot import @oai/sky directly; use mcp__node_repl__js for Computer Use actions.]`; + } + } + + return { text, isError: effectiveIsError }; +} + +/** + * Compacts base64 screenshots and oversized accessibility dumps within a tool-result payload. + */ +export function compactComputerUsePayload(text: string, maxBytes?: number): string { + let compacted = text; + + // 1. Strip data:image base64 payloads (data:image/jpeg;base64,...) + compacted = compacted.replace( + /data:image\/[a-zA-Z0-9+.-]+;base64,[A-Za-z0-9+/=]{20,}/g, + "[Screenshot image omitted for context budget; inspect accessibility tree below or query with get_app_state]", + ); + + // 2. Strip JSON screenshot fields containing long base64 strings + compacted = compacted.replace( + /"screenshot"\s*:\s*"[A-Za-z0-9+/=]{40,}"/g, + '"screenshot": "[Screenshot base64 omitted for context budget]"', + ); + + // 3. Strip JSON image fields containing long base64 strings + compacted = compacted.replace( + /"(?:image|image_data)"\s*:\s*"[A-Za-z0-9+/=]{40,}"/g, + '"image": "[Image base64 omitted for context budget]"', + ); + + // 4. Strip JPEG/PNG base64 signatures (/9j/4AAQSkZJRg... or iVBORw0KGgo...) + compacted = compacted.replace( + /(?:\/9j\/4AAQSkZJRg|iVBORw0KGgo)[A-Za-z0-9+/=]{40,}/g, + "[Screenshot image data omitted for context budget]", + ); + + if (maxBytes === undefined) return compacted; + + const encoded = encoder.encode(compacted); + if (encoded.byteLength <= maxBytes) return compacted; + + // 5. Structure-aware AX tree summarization if over budget + if (compacted.includes("AXTree") || compacted.includes("get_app_state") || compacted.includes("AXUIElement") || compacted.includes("list_apps")) { + const lines = compacted.split("\n"); + + let foundWindow = false; + let foundUrl = false; + let windowInfo = ""; + let urlInfo = ""; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!foundWindow && (/^window\s*:/i.test(trimmedLine) || /^title\s*:/i.test(trimmedLine) || trimmedLine.includes("/Applications/"))) { + windowInfo = trimmedLine; + foundWindow = true; + } + if (!foundUrl) { + const urlMatch = trimmedLine.match(/(?:^url\s*:\s*)?(https?:\/\/\S+)/i); + if (urlMatch) { + urlInfo = urlMatch[1] ?? trimmedLine; + foundUrl = true; + } + } + if (foundWindow && foundUrl) break; + } + + const noteParts: string[] = []; + if (windowInfo) { + const windowLabel = windowInfo.replace(/^window\s*:\s*/i, "").trim(); + noteParts.push(`window: ${windowLabel.slice(0, 120)}`); + } + if (urlInfo) noteParts.push(`url: ${urlInfo}`); + const note = noteParts.length > 0 ? ` (${noteParts.join(", ")})` : ""; + const trailer = `\n…[AX tree summarized for Cursor context budget${note}; query specific elements with get_app_state]${CURSOR_TRUNCATION_MARKER}`; + const trailerBytes = encoder.encode(trailer).byteLength; + const effectiveLimit = Math.max(0, maxBytes - trailerBytes); + + const summaryLines: string[] = []; + let currentBytes = 0; + for (const line of lines) { + const lineBytes = encoder.encode(line).byteLength + (summaryLines.length > 0 ? 1 : 0); + if (currentBytes + lineBytes <= effectiveLimit) { + summaryLines.push(line); + currentBytes += lineBytes; + } else { + break; + } + } + + const result = `${summaryLines.join("\n")}${trailer}`; + if (encoder.encode(result).byteLength <= maxBytes) { + return result; + } + } + + // 6. Safe UTF-8 substring truncation + const markerEncoded = encoder.encode(CURSOR_TRUNCATION_MARKER); + const keepBytes = Math.max(0, maxBytes - markerEncoded.byteLength); + let end = Math.min(encoded.byteLength, keepBytes); + while (end > 0 && end < encoded.byteLength && (encoded[end]! & 0xc0) === 0x80) end -= 1; + return `${decoder.decode(encoded.subarray(0, end))}${CURSOR_TRUNCATION_MARKER}`; +} + +export function formatToolResultToWireText( + message: OcxToolResultMessage, + options?: { maxBytes?: number; compact?: boolean }, +): { text: string; wireOutput: string; isError: boolean } { + const normalized = normalizeToolResultContent( + message.content, + message.toolNamespace, + message.toolName, + message.isError, + ); + + let outputText = normalized.text; + if (options?.compact !== false) { + outputText = compactComputerUsePayload(outputText, options?.maxBytes); + } + + const wireOutput = [ + "[tool_result]", + `call_id: ${message.toolCallId}`, + `name: ${namespacedToolName(message.toolNamespace, message.toolName)}`, + `is_error: ${normalized.isError}`, + "output:", + outputText, + ].join("\n"); + + return { + text: outputText, + wireOutput, + isError: normalized.isError, + }; +} diff --git a/tests/cursor-computer-use-replay.test.ts b/tests/cursor-computer-use-replay.test.ts new file mode 100644 index 0000000000..f8770d49fb --- /dev/null +++ b/tests/cursor-computer-use-replay.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, test } from "bun:test"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { handleCursorNativeKv } from "../src/adapters/cursor/native-exec"; +import { + CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, + encodeCursorRunRequest, +} from "../src/adapters/cursor/protobuf-request"; +import { + AgentClientMessageSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import type { OcxMessage } from "../src/types"; +import { + compactComputerUsePayload, + detectComputerUsePayload, + formatToolResultToWireText, + isNodeReplOrComputerUseTool, + normalizeToolResultContent, +} from "../src/adapters/cursor/tool-result-compaction"; + +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; +} + +function decodeRoots(bytes: Uint8Array): unknown[] { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + return roots.map(id => JSON.parse(new TextDecoder().decode(blobData(id)))); +} + +describe("Issue #1866: Cursor adapter tool-result replay for Computer Use / node_repl", () => { + describe("1. Empty outer exec / node_repl output detection", () => { + test("identifies node_repl and computer use tools", () => { + expect(isNodeReplOrComputerUseTool("js", "mcp__node_repl")).toBe(true); + expect(isNodeReplOrComputerUseTool("mcp__node_repl__js")).toBe(true); + expect(isNodeReplOrComputerUseTool("get_app_state")).toBe(true); + expect(isNodeReplOrComputerUseTool("click", "mcp__computer_use")).toBe(true); + expect(isNodeReplOrComputerUseTool("click", "mcp__playwright")).toBe(false); + expect(isNodeReplOrComputerUseTool("js", "mcp__quickjs")).toBe(false); + expect(isNodeReplOrComputerUseTool("read_file", "mcp__fs")).toBe(false); + }); + + test("converts empty string output from mcp__node_repl__js into a structured error", () => { + const normalized = normalizeToolResultContent("", "mcp__node_repl", "js", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).toContain("empty output: tool executed with no stdout or return value"); + expect(normalized.text).toContain("get_app_state"); + }); + + test("converts whitespace-only output from node_repl into a structured error", () => { + const normalized = normalizeToolResultContent(" \n\t ", undefined, "mcp__node_repl__js", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).toContain("empty output"); + }); + + test("converts outer exec '' wrapper output into a structured error", () => { + const outerExec = "Script completed Wall time 7.9 seconds\nOutput: "; + const normalized = normalizeToolResultContent(outerExec, "mcp__node_repl", "js", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).toContain("empty output"); + }); + + test("leaves ordinary empty output for non-computer-use tools unchanged when isError is false", () => { + const normalized = normalizeToolResultContent("", "mcp__fs", "touch", false); + expect(normalized.isError).toBe(false); + expect(normalized.text).toBe(""); + }); + + test("preserves explicit empty exec wrapper for non-computer-use tools", () => { + const outerExec = "Script completed Wall time 7.9 seconds\nOutput: "; + const normalized = normalizeToolResultContent(outerExec, "mcp__fs", "run", false); + expect(normalized.isError).toBe(false); + expect(normalized.text).toBe(outerExec); + }); + }); + + describe("2. Oversized Computer Use payload compaction", () => { + test("detects Computer Use payloads by content indicators", () => { + expect(detectComputerUsePayload("const { sky } = require('@oai/sky');")).toBe(true); + expect(detectComputerUsePayload("SkyComputerUseError: The user changed '/Applications/Google Chrome.app'")).toBe(true); + expect(detectComputerUsePayload("Window AXTree: { title: 'GitHub' }")).toBe(true); + expect(detectComputerUsePayload("plain text without keywords")).toBe(false); + }); + + test("compacts data:image base64 screenshots while keeping AX text", () => { + const base64Fake = "A".repeat(5000); + const payload = `AXTree dump:\nWindow title: Chrome - Issue #1866\nURL: https://github.com/lidge-jun/opencodex/issues/1866\nScreenshot: data:image/jpeg;base64,${base64Fake}\nButton: Submit`; + const compacted = compactComputerUsePayload(payload); + expect(compacted).not.toContain(base64Fake); + expect(compacted).toContain("Screenshot image omitted for context budget"); + expect(compacted).toContain("Window title: Chrome - Issue #1866"); + expect(compacted).toContain("https://github.com/lidge-jun/opencodex/issues/1866"); + expect(compacted).toContain("Button: Submit"); + }); + + test("compacts JSON screenshot fields with base64 data", () => { + const base64Fake = "/9j/4AAQSkZJRg" + "A".repeat(10_000); + const jsonPayload = JSON.stringify({ + app: "Google Chrome", + url: "https://github.com", + screenshot: base64Fake, + elements: [{ role: "button", title: "Sign in" }], + }); + const compacted = compactComputerUsePayload(jsonPayload); + expect(compacted).not.toContain(base64Fake); + expect(compacted).toContain("Screenshot base64 omitted for context budget"); + expect(compacted).toContain("Google Chrome"); + expect(compacted).toContain("Sign in"); + }); + + test("structure-aware AX tree summarization preserves window title and URL when over byte budget", () => { + const lines = [ + "AXTree:", + "window: Google Chrome - Issue 1866", + "url: https://github.com/lidge-jun/opencodex/issues/1866", + ...Array.from({ length: 500 }, (_, i) => ` AXUIElement[${i}]: role=generic_container id=elem_${i} bounds=(0,0,100,20)`), + ]; + const bigTree = lines.join("\n"); + const compacted = compactComputerUsePayload(bigTree, 2048); + expect(new TextEncoder().encode(compacted).byteLength).toBeLessThanOrEqual(2048); + expect(compacted).toContain("window: Google Chrome"); + expect(compacted).toContain("https://github.com/lidge-jun/opencodex/issues/1866"); + expect(compacted).toContain("AX tree summarized for Cursor context budget"); + expect(compacted).toContain("truncated for Cursor external replay budget"); + }); + }); + + describe("3. SkyComputerUseError / Chrome state change recovery", () => { + test("marks state change as isError and includes get_app_state instruction", () => { + const err = "The user changed '/Applications/Google Chrome.app'."; + const normalized = normalizeToolResultContent(err, "mcp__node_repl", "js", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).toContain("SkyComputerUseError"); + expect(normalized.text).toContain("The user changed '/Applications/Google Chrome.app'"); + expect(normalized.text).toContain("Re-query the latest state with `get_app_state` before sending more actions."); + }); + + test("does not invent a focus change for SkyComputerUseError without a user-changed clause", () => { + const err = "SkyComputerUseError: permission denied"; + const normalized = normalizeToolResultContent(err, "mcp__node_repl", "js", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).not.toContain("the active application"); + expect(normalized.text).toContain("Re-query the latest state with `get_app_state` before sending more actions."); + expect(normalized.text).toContain("permission denied"); + }); + + test("replays SkyComputerUseError in rootPromptMessages as [Tool Error]", () => { + const rawMessages: OcxMessage[] = [ + { role: "user", content: "click button", timestamp: 1 }, + { + role: "assistant", + model: "cursor/grok-4.6", + timestamp: 2, + content: [{ type: "toolCall", id: "c1", name: "js", namespace: "mcp__node_repl", arguments: { script: "sky.click(1)" } }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "js", + toolNamespace: "mcp__node_repl", + content: "SkyComputerUseError: The user changed '/Applications/Google Chrome.app'.", + isError: false, + timestamp: 3, + }, + ]; + + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "c_sky_err", + system: ["system"], + messages: [{ role: "tool", content: "ignored" }], + rawMessages, + }); + + const roots = decodeRoots(bytes); + const serialized = JSON.stringify(roots); + expect(serialized).toContain("[Tool Error]"); + expect(serialized).toContain("SkyComputerUseError: The user changed '/Applications/Google Chrome.app'"); + expect(serialized).toContain("get_app_state"); + }); + }); + + describe("4. node_repl declaration collisions and lost sky bindings", () => { + test("normalizes Identifier collision error with block scope guidance", () => { + const err = "Identifier 'state' has already been declared"; + const normalized = normalizeToolResultContent(err, "mcp__node_repl", "js", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).toContain("Identifier 'state' has already been declared"); + expect(normalized.text).toContain("use var, reassign without let/const, or wrap the snippet in a block scope"); + }); + + test("normalizes missing sky binding error with require('@oai/sky') hint", () => { + const err = "ReferenceError: sky is not defined"; + const normalized = normalizeToolResultContent(err, "mcp__node_repl", "js", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).toContain("sky is not defined"); + expect(normalized.text).toContain("const { sky } = require('@oai/sky');"); + }); + + test("normalizes unsupported import in exec error with mcp__node_repl__js hint", () => { + const err = "unsupported import in exec: @oai/sky"; + const normalized = normalizeToolResultContent(err, undefined, "exec_command", false); + expect(normalized.isError).toBe(true); + expect(normalized.text).toContain("unsupported import in exec"); + expect(normalized.text).toContain("use mcp__node_repl__js"); + }); + }); + + describe("5. End-to-end Computer Use turn replay on cursor/grok-4.6", () => { + test("replays oversized get_app_state AX tree + screenshot without exceeding budget and retaining window metadata", () => { + const axLines = [ + "AXTree snapshot for /Applications/Google Chrome.app:", + "window: Pull Requests · lidge-jun/opencodex", + "url: https://github.com/lidge-jun/opencodex/pulls", + "screenshot: data:image/jpeg;base64," + "B".repeat(200_000), + ...Array.from({ length: 2000 }, (_, i) => ` AXNode[${i}]: link href="/pull/${i}" title="PR ${i}"`), + ]; + const getAppStateOutput = axLines.join("\n"); + + const rawMessages: OcxMessage[] = [ + { role: "user", content: "inspect open PRs in Chrome", timestamp: 1 }, + { + role: "assistant", + model: "cursor/grok-4.6", + timestamp: 2, + content: [{ type: "toolCall", id: "cu_1", name: "js", namespace: "mcp__node_repl", arguments: { script: "await sky.get_app_state()" } }], + }, + { + role: "toolResult", + toolCallId: "cu_1", + toolName: "js", + toolNamespace: "mcp__node_repl", + content: getAppStateOutput, + isError: false, + timestamp: 3, + }, + ]; + + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "c_cu_full", + system: ["You are a desktop automation assistant."], + messages: [{ role: "tool", content: "ignored" }], + rawMessages, + }); + + const roots = decodeRoots(bytes); + const serialized = JSON.stringify(roots); + + // Verify budget is strictly honored + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const rootBytes = (run?.conversationState?.rootPromptMessagesJson ?? []) + .reduce((sum, id) => sum + blobData(id).byteLength, 0); + expect(rootBytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + + // Verify essential information is preserved + expect(serialized).toContain("Pull Requests · lidge-jun/opencodex"); + expect(serialized).toContain("https://github.com/lidge-jun/opencodex/pulls"); + expect(serialized).toContain("[Tool Result]"); + expect(serialized).toContain("Screenshot image omitted for context budget"); + expect(serialized).not.toContain("B".repeat(100)); // giant raw base64 stripped + }); + + test("empty outer-exec output after completed nested call surfaces structured error in rootPromptMessagesJson", () => { + const rawMessages: OcxMessage[] = [ + { role: "user", content: "click submit", timestamp: 1 }, + { + role: "assistant", + model: "cursor/grok-4.6", + timestamp: 2, + content: [{ type: "toolCall", id: "cu_2", name: "js", namespace: "mcp__node_repl", arguments: { script: "await sky.click(14)" } }], + }, + { + role: "toolResult", + toolCallId: "cu_2", + toolName: "js", + toolNamespace: "mcp__node_repl", + content: "Script completed Wall time 7.9 seconds\nOutput: ", + isError: false, + timestamp: 3, + }, + ]; + + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "c_cu_empty", + system: ["system"], + messages: [{ role: "tool", content: "ignored" }], + rawMessages, + }); + + const roots = decodeRoots(bytes); + const serialized = JSON.stringify(roots); + expect(serialized).toContain("[Tool Error]"); + expect(serialized).toContain("empty output: tool executed with no stdout or return value"); + expect(serialized).toContain("get_app_state"); + }); + + test("multi-turn replay keeps the trailing tool result and stays within the root budget", () => { + const bigAx = [ + "AXTree snapshot for /Applications/Google Chrome.app:", + "window: Pull Requests · lidge-jun/opencodex", + "url: https://github.com/lidge-jun/opencodex/pulls", + ...Array.from({ length: 4000 }, (_, i) => ` AXNode[${i}]: link href="/pull/${i}" title="PR ${i}"`), + ].join("\n"); + + const rawMessages: OcxMessage[] = [ + { role: "user", content: "walk the PR list", timestamp: 1 }, + ...[0, 1, 2].flatMap(n => [ + { + role: "assistant", + model: "cursor/grok-4.6", + timestamp: 2 + n * 2, + content: [{ type: "toolCall", id: `cu_${n}`, name: "js", namespace: "mcp__node_repl", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: `cu_${n}`, + toolName: "js", + toolNamespace: "mcp__node_repl", + content: `turn marker ${n}\n${bigAx}`, + isError: false, + timestamp: 3 + n * 2, + }, + ]), + ]; + + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "c_cu_multi", + system: ["system"], + messages: [{ role: "tool", content: "ignored" }], + rawMessages, + }); + + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const rootBytes = (run?.conversationState?.rootPromptMessagesJson ?? []) + .reduce((sum, id) => sum + blobData(id).byteLength, 0); + + expect(rootBytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + + const serialized = JSON.stringify(decodeRoots(bytes)); + expect(serialized).toContain("turn marker 2"); + expect(serialized).toContain("turn marker 1"); + }); + }); +}); diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index f3aaafd656..1a84cf3b23 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -255,6 +255,29 @@ describe("Cursor request builder", () => { }]); }); + test("normalizes empty node_repl output into structured tool-result errors in request messages", () => { + const request = createCursorRequest({ + ...base, + context: { + messages: [{ + role: "toolResult", + toolCallId: "call_cu", + toolName: "js", + toolNamespace: "mcp__node_repl", + content: "Script completed Wall time 7.9 seconds\nOutput: ", + isError: false, + timestamp: 1, + }], + }, + }); + + expect(request.messages).toHaveLength(1); + expect(request.messages[0]?.role).toBe("tool"); + expect(request.messages[0]?.content).toContain("is_error: true"); + expect(request.messages[0]?.content).toContain("empty output"); + expect(request.messages[0]?.content).toContain("get_app_state"); + }); + test("preserves Responses allowed_tools and parallel_tool_calls controls from parser", () => { const parsed = parseRequest({ model: "cursor/auto",