diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index e1cf381bf31..1a788461e3b 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -544,7 +544,13 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and Foreground `shellArgs` and `shellStreamArgs` are an exception: both are rejected before spawn on every platform until kernel-backed descendant ownership is available. Use client shell tools; background-shell execution and other native operations retain their existing policy. -- The denial reply is a silent redirect whose wording follows the request catalog. +- The denial reply follows the request catalog. Explicit Claude-family Cursor models receive + factual wording that names the available tools without instructions to hide or avoid discussing + the redirect. Factual routing commentary can precede the next tool call without ending the turn. + When no client or configured MCP tools are available, Claude models are asked to answer without + tools or report the limitation instead of calling nonexistent shell/edit tools. + Other models, including Auto/default, retain the existing silent-redirect wording and commentary + guard. Tool availability, execution policy, and approval/sandbox behavior are unchanged. In code mode — a freeform unified `exec` and no bare shell bridge — the redirect points inside `exec`, where shell, file, search, and fetch are nested `tools.(...)` helpers of the JavaScript cell, and never recommends the top-level shell bridge code mode does not expose. A flat catalog that diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b940f08da39..d2c7a6be124 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -819,7 +819,7 @@ "cursor-tool-finalize-race.test.ts": "providers/cursor", "cursor-tool-result-image.test.ts": "providers/cursor", "cursor-tool-result-invocation.test.ts": "providers/cursor", - "cursor-tool-suspended-checkpoint.test.ts": "providers/cursor", + "cursor-tool-suspended-checkpoint.test.ts": "providers/cursor", "cursor-tool-wording.test.ts": "providers/cursor", "cursor-toolresult-normalize.test.ts": "providers/cursor", "cursor-transport-retry.test.ts": "providers/cursor", "cursor-ultra-mode.test.ts": "providers/cursor", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 92801783c08..d9aa283fbc7 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -65,6 +65,7 @@ import { type CursorTransportFactory, } from "./cursor/transport"; import { cursorLiveRosterScope } from "./cursor/catalog"; +import { cursorUsesPlainToolWording } from "./cursor/tool-wording"; export const CURSOR_API_URL = "https://api2.cursor.sh"; @@ -313,8 +314,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const armMidstreamEcho = cursorNeedsExternalToolContinuation(activeRequest.modelId) && replaysToolResult; const midstreamObserver = armMidstreamEcho ? new CursorMidstreamEchoObserver() : undefined; const midstreamFilter = armMidstreamEcho ? new ToolEnvelopeEchoFilter() : undefined; + // Claude's factual redirects allow truthful routing prose; it is not a failure claim. const armRoutingCommentarySniffer = isCursorExternalWireModel(activeRequest.modelId) + && !cursorUsesPlainToolWording(activeRequest.modelId) && ( cursorRequestUsesCodeMode(activeRequest.tools, activeRequest.toolChoice) || cursorRequestHasShellAlias(activeRequest.tools) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index b68464d14c5..b5fe5ca31fd 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -16,6 +16,7 @@ import { import { activePromptText, prepareCursorRunRequest } from "./protobuf-request"; import { prepareCursorRawMessages, resolveActiveCursorImages } from "./images"; import { isCursorExternalWireModel } from "./discovery"; +import { cursorUsesPlainToolWording } from "./tool-wording"; import { cursorRequestMessagesFromRaw } from "./request-builder"; import { createCursorContextUsageTracker, @@ -718,7 +719,8 @@ class LiveCursorTransport implements CursorTransport { clientToolDefs, rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice), structuredEditAvailable: syntheticStructuredEditToolNames.size > 0, - nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs ?? []), + nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs ?? [], activeRequest.modelId), + plainToolWording: cursorUsesPlainToolWording(activeRequest.modelId), }; const toolSchemas = new Map(); const cursorToolNameMap = new Map(); diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index 2558f91faca..b9bc94a7434 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -35,14 +35,17 @@ import { } from "./gen/agent_pb"; import { errorText, execBytes, lineCount, textDecoder, textEncoder } from "./native-exec-common"; +import { CURSOR_TOOL_CALL_CONTINUATION } from "./tool-wording"; + const MAX_GREP_FILES = 500; const MAX_GREP_RESULTS = 200; const MAX_FILE_BYTES = 1_000_000; -function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean): string { +function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean, plainToolWording = false): string { const structuredHint = structuredEditAvailable ? " Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout." : " Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout."; + if (plainToolWording) return `Cursor-native ${operation} is not available for this request.${structuredHint} No file was changed. ${CURSOR_TOOL_CALL_CONTINUATION}`; return `Make this ${operation} through the Codex edit path instead.${structuredHint} No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call.`; } @@ -87,24 +90,24 @@ export function readExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array { +export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "writeResult", create(WriteResultSchema, { result: { case: "rejected", - value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable) }), + value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable, plainToolWording) }), }, })); } -export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { +export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, hint?: string, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "writeResult", create(WriteResultSchema, { result: { case: "rejected", - value: create(WriteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(WriteRejectedSchema, { path, reason: plainToolWording ? `No file was changed. ${hint ?? NATIVE_LOCAL_EXEC_DISABLED}` : `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } @@ -136,24 +139,24 @@ export function writeExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array { +export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, { result: { case: "rejected", - value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable) }), + value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable, plainToolWording) }), }, })); } -export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { +export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, hint?: string, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, { result: { case: "rejected", - value: create(DeleteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(DeleteRejectedSchema, { path, reason: plainToolWording ? `No file was changed. ${hint ?? NATIVE_LOCAL_EXEC_DISABLED}` : `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index aa195e31dbe..56cfb9a8041 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -57,6 +57,7 @@ import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { CODEX_UNIFIED_EXEC_TOOL, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolWireName } from "./tool-naming"; import { CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; import type { OcxTool } from "../../types"; +import { cursorPlainNativeExecFallback, cursorPlainNativeExecRedirectHint, cursorUsesPlainToolWording } from "./tool-wording"; export type CursorNativeExecDeps = CursorNativeNetworkDeps & CursorNativeToolDeps; @@ -80,6 +81,8 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps { structuredEditAvailable?: boolean; /** Catalog-aware redirect text for denied native fs/shell attempts (undefined = default bridge wording). */ nativeExecRedirectHint?: string; + /** Claude-family Cursor targets receive factual redirects, without narration restrictions. */ + plainToolWording?: boolean; } const REDIRECT_HINT_MAX_TOOLS = 16; @@ -93,7 +96,9 @@ const REDIRECT_HINT_MAX_TOOLS = 16; export function cursorNativeExecRedirectHint( tools: readonly Pick[] | undefined, mcpToolDefs: readonly Pick[] = [], + modelId?: string, ): string | undefined { + if (cursorUsesPlainToolWording(modelId)) return cursorPlainNativeExecRedirectHint(tools, mcpToolDefs); const clientTools = tools ?? []; if (cursorRequestHasShellAlias(clientTools)) return undefined; // Code mode (freeform unified `exec`, no bare shell bridge): the default bridge wording names @@ -704,6 +709,9 @@ export function cursorBlobStoreDebugSnapshotForTests(): Array<{ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: CursorNativeExecContext = {}): Promise { const execCase = execMsg.message.case; + if (deps.plainToolWording && !deps.nativeExecRedirectHint) { + deps = { ...deps, nativeExecRedirectHint: cursorPlainNativeExecFallback([...(deps.clientToolDefs ?? []), ...(deps.mcpToolDefs ?? [])]) }; + } if (execCase === "requestContextArgs") { const tools = [...(deps.mcpToolDefs ?? []), ...(deps.clientToolDefs ?? [])]; return [execBytes(execMsg, "requestContextResult", create(RequestContextResultSchema, { @@ -712,8 +720,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C } if (!cursorUnsafeNativeLocalExecEnabled(deps)) { if (execCase === "readArgs") return [rejectReadExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; - if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; - if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, deps.nativeExecRedirectHint, deps.plainToolWording)]; + if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, deps.nativeExecRedirectHint, deps.plainToolWording)]; if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; @@ -723,8 +731,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; } if (execCase === "readArgs") return [readExec(execMsg)]; - if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)]; - if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : deleteExec(execMsg)]; + if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true, deps.plainToolWording) : writeExec(execMsg)]; + if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true, deps.plainToolWording) : deleteExec(execMsg)]; if (execCase === "lsArgs") return [lsExec(execMsg)]; if (execCase === "grepArgs") return [grepExec(execMsg)]; if (execCase === "shellArgs") return [shellExec(execMsg, deps.nativeExecRedirectHint)]; diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 732a66e27b2..a4264f20112 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -224,6 +224,7 @@ function systemPromptBlobs(request: CursorRunRequest): RootBlobCandidate[] { const cursorToolGuidance = buildCursorToolGuidanceSystemNote( cursorToolsForActivePrompt(request.tools, activePromptText(request), request.toolChoice), request.toolChoice, + request.modelId, ); if (cursorToolGuidance) prompts.push(cursorToolGuidance); return prompts.map(content => rootBlobCandidate({ role: "system", content }, "system")); diff --git a/src/adapters/cursor/tool-guidance.ts b/src/adapters/cursor/tool-guidance.ts index f929d5c93c0..f8df7223bc3 100644 --- a/src/adapters/cursor/tool-guidance.ts +++ b/src/adapters/cursor/tool-guidance.ts @@ -1,4 +1,5 @@ import type { OcxRequestOptions, OcxTool } from "../../types"; +import { CURSOR_TOOL_CALL_CONTINUATION, cursorUsesPlainToolWording } from "./tool-wording"; import { CODE_MODE_HOST_CONTRACT_SENTENCE, CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EXEC_TOOL, clientSemanticToolNameFromCursorWire, cursorRequestAdvertisesApplyPatch, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolAllowedByChoice, cursorToolWireName, isBareCodexShellBridgeTool, isClaudeClientBareToolName, isCodexShellBridgeToolName, isCursorExecutionPathTool, isCursorStructuredEditToolName } from "./tool-naming"; @@ -152,8 +153,10 @@ function discoveryToolLabel(wireNames: readonly string[]): string | undefined { export function buildCursorToolGuidanceSystemNote( tools: readonly Pick[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], + modelId?: string, ): string | undefined { if (!tools?.length) return undefined; + const plainToolWording = cursorUsesPlainToolWording(modelId); const wireNames = [...new Set( tools .filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)) @@ -187,6 +190,9 @@ export function buildCursorToolGuidanceSystemNote( const notes = [ `Cursor tool calls: available tool names are exactly ${listedNames}.`, "Use the current tool catalog as ground truth and call only those exact names with their listed argument keys.", + plainToolWording && !codeMode && !hasBareExec + ? "Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not available in this request. Use the listed tool that fits the operation." + : undefined, unavailableNeighborNames.length > 0 ? `This turn does not expose neighboring-agent tool names ${quotedNames(unavailableNeighborNames)}; do not call or suggest them unless the catalog lists them.` : undefined, @@ -199,18 +205,24 @@ export function buildCursorToolGuidanceSystemNote( ? CODE_MODE_RESULT_ECHO_SENTENCE + " There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers. " + CODE_MODE_HOST_CONTRACT_SENTENCE : undefined, codeMode - ? "NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces." + ? plainToolWording + ? "Cursor-native Shell, Read, Grep, List, and Write are not available in this request. The exec code cell and its nested helpers provide the execution path; separately listed tools remain callable at the top level." + : "NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces." : undefined, hasBareExec ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.` : undefined, hasBareExec - ? "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user." + ? plainToolWording + ? "Your tool list may display the same shell bridge under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; call the name listed in the catalog." + : "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user." : undefined, hasBareExec - ? `NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. ${shellBridgeLabel} is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces ("native is blocked, using the bridge instead") — there is exactly one surface.` + ? plainToolWording + ? `Cursor-native Shell, Read, Grep, List, and Write are not available in this request. ${shellBridgeLabel} provides shell execution through the Codex client host.` + : `NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. ${shellBridgeLabel} is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces ("native is blocked, using the bridge instead") — there is exactly one surface.` : undefined, - hasBareExec + hasBareExec && !plainToolWording ? "Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons." : undefined, hostShellNote, @@ -241,5 +253,6 @@ export function buildCursorToolGuidanceSystemNote( ? `For every file read, directory listing, grep, or shell operation use ${shellBridgeLabel} directly with host-shell-safe commands (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). For file edits, use ${structuredEditNames.length > 0 ? `the structured edit tools (${quotedNames(structuredEditNames)}) or ` : ""}\`apply_patch\` when available.` : undefined, ].filter((note): note is string => typeof note === "string"); + if (plainToolWording) notes.push(CURSOR_TOOL_CALL_CONTINUATION); return notes.join(" "); } diff --git a/src/adapters/cursor/tool-wording.ts b/src/adapters/cursor/tool-wording.ts new file mode 100644 index 00000000000..be35eb1acbc --- /dev/null +++ b/src/adapters/cursor/tool-wording.ts @@ -0,0 +1,46 @@ +import type { OcxTool } from "../../types"; +import type { McpToolDefinition } from "./gen/agent_pb"; +import { CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; +import { OCX_RESPONSES_TOOL_PROVIDER, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolWireName } from "./tool-naming"; + +/** Cursor may expose Claude with version-first, family-first, or effort-suffixed ids. */ +export function cursorUsesPlainToolWording(modelId?: string): boolean { + return /^(?:cursor\/)?claude-/i.test(modelId?.trim() ?? ""); +} + +export const CURSOR_TOOL_CALL_CONTINUATION = "Continue the task with that tool call."; +const CURSOR_NO_CLIENT_TOOLS = "Cursor-native filesystem, shell, and fetch tools are not available through this execution channel. No client tools are available in this request. Answer without tools using the information already provided, or report that the requested operation cannot be completed without tools."; + +/** Factual catalog wording; legacy model wording is owned by native-exec.ts unchanged. */ +export function cursorPlainNativeExecRedirectHint( + tools: readonly Pick[] | undefined, + mcpToolDefs: readonly Pick[], +): string | undefined { + const clientTools = tools ?? []; + const names = [...new Set([ + ...clientTools.map(tool => cursorToolWireName(tool, clientTools)), + ...mcpToolDefs.map(def => `mcp_${def.providerIdentifier}_${def.name}`), + ])]; + if (names.length === 0) return CURSOR_NO_CLIENT_TOOLS; + const shown = names.slice(0, 16).map(name => `\`${name}\``).join(", "); + const more = names.length > 16 ? ` (+${names.length - 16} more)` : ""; + const catalog = `Available tools in this request's catalog: ${shown}${more}. ` + + `The harness displays a \`${OCX_RESPONSES_TOOL_PROVIDER}\` entry as \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_\`; that is the same tool. ` + + "Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not available in this request. "; + if (!cursorRequestHasShellAlias(clientTools) && cursorRequestUsesCodeMode(clientTools)) { + return catalog + "`exec` takes a JavaScript body evaluated in a V8 isolate, not a shell command. Shell, file, search, and fetch are nested helpers called inside it as `await tools.(...)`, for example `text(await tools.exec_command({cmd: \"ls\"}))`. " + + "`shell_command` and `exec_command` are nested helpers, not top-level tools here. Every other listed tool remains callable at the top level. " + + CODE_MODE_RESULT_ECHO_SENTENCE + " " + CURSOR_TOOL_CALL_CONTINUATION; + } + const operation = cursorRequestHasExecutionPath(clientTools) + ? "Use the listed execution tool with its advertised argument schema and the Codex client host's shell syntax; use a listed edit tool for file edits. " + : "Use the listed tool that fits the operation: a file, search, or fetch tool when provided, or a listed worker-agent delegation tool. "; + return catalog + operation + CURSOR_TOOL_CALL_CONTINUATION; +} + +/** Fallback uses advertised dispatcher definitions, never assumed shell/edit tools. */ +export function cursorPlainNativeExecFallback( + toolDefs: readonly Pick[] = [], +): string { + return cursorPlainNativeExecRedirectHint([], toolDefs)!; +} diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 766f3231129..19a500bf880 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -48,7 +48,25 @@ expose; separately listed namespaced tools remain callable as usual. A flat cata bridge or a non-freeform unified `exec` keeps the default bridge wording, and a catalog with no execution path at all keeps naming the request's actual client/MCP wire names. -In external Cursor turns using code mode or shell aliases, the bounded leading-commentary guard in `src/adapters/cursor/envelope-echo.ts` counts `Shell`, `네이티브 셸`, and `네이티브 쉘` as one `shell` identity, including spacing variants and names split across text deltas. Korean aliases require a Unicode-aware left token boundary so wording embedded in a larger word or identifier is not counted; punctuation and following Korean grammatical suffixes remain supported. Rejection still requires a failure claim plus either an explicit redirect or at least two distinct native-tool identities; repeated aliases alone do not count as multiple tools. +Explicit Claude-family targets (`claude-*`, including `cursor/claude-*` selections) receive +factual tool guidance and native-exec redirects from `src/adapters/cursor/tool-wording.ts`: +they name available catalog tools, state that Cursor-native tools are unavailable, and end with +“Continue the task with that tool call.” An empty client/MCP catalog instead states that no client +tools are available and asks for an answer without tools or a report of the limitation; it never +names assumed shell/edit tools. Catalog-less dispatcher fallback uses its advertised definitions. +They impose no tool-routing narration restrictions. `src/adapters/cursor.ts` leaves the routing- +commentary sniffer unarmed for these targets so factual routing prose can precede a tool call; +tool-envelope echo guards remain active under their existing predicates. +`src/adapters/cursor/protobuf-request.ts` uses the final request model for system guidance; +`src/adapters/cursor/live-transport.ts` re-derives the redirect and `plainToolWording` exec flag +on every turn. The flag also covers catalog-less fallback and apply-patch mutation refusals. +Other targets, including Auto/default whose underlying family is unknown, retain byte-identical +legacy wording. Execution policy, tool names, argument schemas, and denial wire shapes are unchanged. +`tests/providers/cursor/cursor-tool-wording.test.ts` covers encoded roots, per-turn target changes, +all denied fs/shell/fetch frame kinds, empty catalogs, factual-prose continuations through the next +tool call, and pre-change non-Claude wording snapshots. + +In non-Claude external Cursor turns using code mode or shell aliases, the bounded leading-commentary guard in `src/adapters/cursor/envelope-echo.ts` counts `Shell`, `네이티브 셸`, and `네이티브 쉘` as one `shell` identity, including spacing variants and names split across text deltas. Korean aliases require a Unicode-aware left token boundary so wording embedded in a larger word or identifier is not counted; punctuation and following Korean grammatical suffixes remain supported. Rejection still requires a failure claim plus either an explicit redirect or at least two distinct native-tool identities; repeated aliases alone do not count as multiple tools. > Decision record: [ADR-0048](../decisions/ADR-0048-cursor-native-exec.md) @@ -62,6 +80,10 @@ Cursor-private tool collisions. Other non-proxy-owned bare tools always receive proxy-owned execution/edit tools and namespaced MCP tools retain their existing identities. Registration, guidance, denied-native-exec redirects, live call mapping, and protobuf history replay use this catalog-dependent rule. Return mapping restores the client's semantic tool name. +Claude factual redirects resolve each tool against the complete visible request catalog, regardless +of declaration order, so their listed names match registration with or without a bare shell bridge. +`tests/providers/cursor/cursor-tool-wording.test.ts` checks both bridge orders and no-bridge catalogs +through redirect guidance and live transport request preparation. Request construction resolves accepted bare wire aliases in forced and allowed-tool choices to semantic names against the original catalog, retaining only the originally selected identities before prompt and budget filtering so a removed bridge cannot invalidate registration or widen diff --git a/tests/fixtures/cursor-tool-wording.json b/tests/fixtures/cursor-tool-wording.json new file mode 100644 index 00000000000..7f84e3603b8 --- /dev/null +++ b/tests/fixtures/cursor-tool-wording.json @@ -0,0 +1,68 @@ +{ + "catalogs": { + "shell": [ + { + "name": "shell_command" + } + ], + "code": [ + { + "name": "exec", + "freeform": true + }, + { + "name": "lookup", + "namespace": "docs" + } + ], + "client": [ + { + "name": "task" + }, + { + "name": "read_file" + } + ], + "mixed": [ + { + "name": "exec", + "freeform": true + }, + { + "name": "exec_command" + }, + { + "name": "apply_patch", + "freeform": true + } + ] + }, + "guidance": { + "shell": "Cursor tool calls: available tool names are exactly `shell_command`. Use the current tool catalog as ground truth and call only those exact names with their listed argument keys. This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`, `Write`; do not call or suggest them unless the catalog lists them. `shell_command` is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. `shell_command` and `exec_command` are aliases of the same bridge. Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user. NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. `shell_command` is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces (\"native is blocked, using the bridge instead\") — there is exactly one surface. Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons. Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<(...)`, for example `text(await tools.exec_command({cmd: \"ls\"}))`. Read the tool description and the isolate global `ALL_TOOLS` (not `tools.ALL_TOOLS`) for helpers this turn provides; absence from the top-level catalog or from `exec`'s description is not absence. Those nested helpers are not themselves top-level tools, so do not call `exec_command` or `shell_command` at the top level here; every other tool this turn lists, including `docs__lookup`, remains callable at the top level as usual. Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch`, each marker line being three asterisks, one space, the two words, then end of line with no further asterisks. OpenCodex does not rewrite JavaScript inside exec, so extra asterisks on a marker line are rejected by Codex before the file is touched. Nothing in the isolate is echoed automatically: a bare trailing `await tools.(...)` or final expression value is DISCARDED, and the cell reports empty output. Pass anything you need to read to `text(...)` (or `notify(...)`) in the same cell — for example `text(JSON.stringify(await tools.exec_command({cmd: 'ls'})))` — and treat an empty result as your own missing `text(...)` call rather than a failed command or lost context. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers. Host contract for the nested helpers: `tools.apply_patch(patch)` takes exactly one string, never an object such as `{input: ...}`; the patch text opens with the bare marker line `*** Begin Patch` and closes with the bare marker line `*** End Patch`, written without a code fence, prose, or extra asterisks on those lines (blank lines or indentation around the markers are tolerated; a decorated or missing marker is rejected). The isolate has no `import`, `require`, or module loader; use the globals the exec tool description lists (for example `tools`, `text`, `notify`, `store`/`load`, `ALL_TOOLS`). For a command that may outlive `yield_time_ms`, let `tools.exec_command` return a `session_id` and poll it on later calls with `tools.write_stdin({session_id, chars: \"\"})` instead of blocking a shell in a sleep loop. NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces. Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise. For independent read-only tool-count or batch requests, prefer one response containing multiple tool calls before waiting for results when the runtime supports parallel tool calls. Do not count or report a tool call unless a tool result was actually returned.", + "client": "Cursor tool calls: available tool names are exactly `ocx_client_task`, `ocx_client_read_file`. Use the current tool catalog as ground truth and call only those exact names with their listed argument keys. This turn does not expose neighboring-agent tool names `Grep`, `Glob`, `Bash`, `LS`, `Write`; do not call or suggest them unless the catalog lists them. Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise. For independent read-only tool-count or batch requests, prefer one response containing multiple tool calls before waiting for results when the runtime supports parallel tool calls. Do not count or report a tool call unless a tool result was actually returned.", + "mixed": "Cursor tool calls: available tool names are exactly `exec`, `exec_command`, `apply_patch`. Use the current tool catalog as ground truth and call only those exact names with their listed argument keys. This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`, `Write`; do not call or suggest them unless the catalog lists them. `exec_command` is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. `shell_command` and `exec_command` are aliases of the same bridge. Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user. NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. `exec_command` is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces (\"native is blocked, using the bridge instead\") — there is exactly one surface. Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons. Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<`, `>>`, `printf`/`echo` into a file, `cat <(...)`, for example `text(await tools.exec_command({cmd: \"ls\"}))`. Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them, and do not call `shell_command` or `exec_command` at the top level here — code mode exposes no bare shell bridge, only the nested helpers. Every other tool this turn lists remains callable at the top level as usual. Nothing in the isolate is echoed automatically: a bare trailing `await tools.(...)` or final expression value is DISCARDED, and the cell reports empty output. Pass anything you need to read to `text(...)` (or `notify(...)`) in the same cell — for example `text(JSON.stringify(await tools.exec_command({cmd: 'ls'})))` — and treat an empty result as your own missing `text(...)` call rather than a failed command or lost context. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the `exec` call.", + "client": "Re-issue this operation NOW through one of the tools listed in this request's catalog: `ocx_client_task`, `ocx_client_read_file` (the harness displays a `opencodex-responses` entry as `mcp_opencodex-responses_`; that is the same tool). Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them. Pick the listed tool that fits the operation — a listed file, search, or fetch tool if there is one, otherwise the listed tool that delegates work to a worker agent. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the catalog tool call.", + "mcp": "Re-issue this operation NOW through one of the tools listed in this request's catalog: `mcp_docs_lookup` (the harness displays a `opencodex-responses` entry as `mcp_opencodex-responses_`; that is the same tool). Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them. Pick the listed tool that fits the operation — a listed file, search, or fetch tool if there is one, otherwise the listed tool that delegates work to a worker agent. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the catalog tool call." + }, + "defaults": { + "fs": "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call.", + "fetch": "Re-issue this fetch NOW through the catalog shell tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do NOT narrate this redirect or comment on tool availability — just make the bridge call.", + "shell": "Re-issue this command NOW through the catalog shell tool (`shell_command` or `exec_command`; the long `mcp_opencodex-responses_*` display name is the same tool). Adapt the command for the Codex client host shell before calling the bridge (Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call.", + "write": "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call. No file was changed.", + "delete": "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call. No file was changed." + }, + "mutation": { + "false": { + "write": "Make this write through the Codex edit path instead. Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call.", + "delete": "Make this delete through the Codex edit path instead. Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call." + }, + "true": { + "write": "Make this write through the Codex edit path instead. Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call.", + "delete": "Make this delete through the Codex edit path instead. Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call." + } + } +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 865f88dae7f..0ac1996adfa 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -502,6 +502,7 @@ "cursor-tool-finalize-race.test.ts": "providers/cursor", "cursor-tool-result-image.test.ts": "providers/cursor", "cursor-tool-result-invocation.test.ts": "providers/cursor", "cursor-tool-suspended-checkpoint.test.ts": "providers/cursor", + "cursor-tool-wording.test.ts": "providers/cursor", "cursor-toolresult-normalize.test.ts": "providers/cursor", "cursor-transport-retry.test.ts": "providers/cursor", "cursor-ultra-mode.test.ts": "providers/cursor", "cursor-umbrella-rows.test.ts": "providers/cursor", "cursor-uncallable-quarantine.test.ts": "providers/cursor", "cursor-vision-wire-harness.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-tool-wording.test.ts b/tests/providers/cursor/cursor-tool-wording.test.ts new file mode 100644 index 00000000000..ebda9f259e2 --- /dev/null +++ b/tests/providers/cursor/cursor-tool-wording.test.ts @@ -0,0 +1,299 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + AgentClientMessageSchema, BackgroundShellSpawnArgsSchema, DeleteArgsSchema, ExecServerMessageSchema, + FetchArgsSchema, GrepArgsSchema, LsArgsSchema, ReadArgsSchema, ShellArgsSchema, WriteArgsSchema, WriteShellStdinArgsSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; +import { createCursorAdapter } from "../../../src/adapters/cursor"; +import { CURSOR_ROUTING_COMMENTARY_RETRY_TEXT } from "../../../src/adapters/cursor/envelope-echo"; +import { createLiveCursorTransport } from "../../../src/adapters/cursor/live-transport"; +import { cursorBlobTextForEstimate, cursorNativeExecRedirectHint, handleCursorNativeExec, resetCursorBlobStateForTests, type CursorNativeExecContext } from "../../../src/adapters/cursor/native-exec"; +import { nativeShellDisabledMessage } from "../../../src/adapters/cursor/native-exec-shell"; +import { encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { createCursorRequest } from "../../../src/adapters/cursor/request-builder"; +import { buildCursorToolGuidanceSystemNote } from "../../../src/adapters/cursor/tool-guidance"; +import { buildCursorToolDefinitions } from "../../../src/adapters/cursor/tool-definitions"; +import { CURSOR_TOOL_CALL_CONTINUATION, cursorUsesPlainToolWording } from "../../../src/adapters/cursor/tool-wording"; +import { parseRequest } from "../../../src/responses/parser"; +import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; +import type { AdapterEvent } from "../../../src/types"; +import legacy from "../../fixtures/cursor-tool-wording.json"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const CONCEALMENT = /do not (narrate|comment|mention|re-announce)|commentary is forbidden|must not appear in your output/i; +const CLAUDE_MODELS = ["claude-sonnet-5-5", "cursor/claude-4.6-opus-high", "claude-opus-5-thinking-high-fast", "claude-4.5-haiku", "claude-fable-5", "claude-4-sonnet-1m"]; +const OTHER_MODELS = [undefined, "cursor-grok-4.6-high", "cursor/gpt-5", "gemini-3-pro", "composer-2.5", "cursor/auto", "default", "not-claude-sonnet"]; +const CLAUDE_CLIENT_CATALOGS = [ + { names: ["Bash", "Read"], wireNames: ["Bash", "Read"] }, + { names: ["Read", "exec_command"], wireNames: ["ocx_client_Read", "exec_command"] }, + { names: ["exec_command", "Read"], wireNames: ["exec_command", "ocx_client_Read"] }, +]; + +function execMessage(message: Parameters>[1]["message"]) { + return create(ExecServerMessageSchema, { id: 1, execId: "wording-test", message }); +} +function refusalTexts(bytes: Uint8Array[]): string[] { + const texts: string[] = []; + function visit(value: unknown) { + if (!value || typeof value !== "object") return; + for (const [key, entry] of Object.entries(value)) { + if (["error", "reason", "stderr", "data"].includes(key) && typeof entry === "string" && entry) texts.push(entry); + else visit(entry); + } + } + for (const reply of bytes) visit(fromBinary(AgentClientMessageSchema, reply)); + expect(texts.length).toBeGreaterThan(0); + return texts; +} +const frames = { + read: execMessage({ case: "readArgs", value: create(ReadArgsSchema, { path: "fixture.txt" }) }), + ls: execMessage({ case: "lsArgs", value: create(LsArgsSchema, { path: "fixture-dir" }) }), + grep: execMessage({ case: "grepArgs", value: create(GrepArgsSchema, { path: "fixture-dir", pattern: "fixture" }) }), + write: execMessage({ case: "writeArgs", value: create(WriteArgsSchema, { path: "fixture.txt", fileText: "unwritten" }) }), + delete: execMessage({ case: "deleteArgs", value: create(DeleteArgsSchema, { path: "fixture.txt" }) }), + shell: execMessage({ case: "shellArgs", value: create(ShellArgsSchema, { command: "echo fixture" }) }), + stream: execMessage({ case: "shellStreamArgs", value: create(ShellArgsSchema, { command: "echo fixture" }) }), + background: execMessage({ case: "backgroundShellSpawnArgs", value: create(BackgroundShellSpawnArgsSchema, { command: "echo fixture" }) }), + stdin: execMessage({ case: "writeShellStdinArgs", value: create(WriteShellStdinArgsSchema, { shellId: 1, chars: "fixture" }) }), + fetch: execMessage({ case: "fetchArgs", value: create(FetchArgsSchema, { url: "https://example.com" }) }), +}; + +afterEach(() => resetCursorBlobStateForTests()); + +describe("Cursor target-specific tool wording", () => { + test.each(CLAUDE_CLIENT_CATALOGS)("Claude redirect matches the request catalog: %j", ({ names, wireNames }) => { + const tools = names.map(name => ({ name, description: "Fixture tool", parameters: {} })); + const definitions = buildCursorToolDefinitions(tools); + expect(definitions.map(tool => tool.name)).toEqual(wireNames); + const hint = cursorNativeExecRedirectHint(tools, [], "claude-sonnet-5-5")!; + expect(hint.match(/Available tools in this request's catalog: (.*?)\. /)?.[1]) + .toBe(wireNames.map(name => `\`${name}\``).join(", ")); + const guidance = buildCursorToolGuidanceSystemNote(tools, undefined, "claude-sonnet-5-5")!; + for (const name of wireNames) expect(guidance).toContain(`\`${name}\``); + }); + test.each(CLAUDE_CLIENT_CATALOGS)("Claude live transport prepares the request catalog: %j", async ({ names, wireNames }) => { + const transport = createLiveCursorTransport({ provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "fixture-token" }, + translatorBudget: createTestTranslatorBudget(), headers: new Headers() }); + const internal = transport as unknown as { open(bytes: Uint8Array): void; execContext: CursorNativeExecContext }; + let preparedBytes: Uint8Array | undefined; + internal.open = bytes => { preparedBytes = bytes; throw new Error("catalog fixture stops before network"); }; + try { + const request: CursorRunRequest = { modelId: "claude-sonnet-5-5", conversationId: "catalog-fixture", system: [], + messages: [{ role: "user", content: "Inspect the project." }], + tools: names.map(name => ({ name, description: "Fixture tool", parameters: {} })) }; + await expect(transport.run(request)[Symbol.asyncIterator]().next()).rejects.toThrow("catalog fixture stops before network"); + expect(preparedBytes).toBeDefined(); + const decoded = fromBinary(AgentClientMessageSchema, preparedBytes!); + if (decoded.message.case !== "runRequest") throw new Error("Expected runRequest"); + expect(decoded.message.value.mcpTools?.mcpTools.map(tool => tool.name)).toEqual(wireNames); + expect(internal.execContext.clientToolDefs?.map(tool => tool.name)).toEqual(wireNames); + expect(internal.execContext.nativeExecRedirectHint?.match(/Available tools in this request's catalog: (.*?)\. /)?.[1]) + .toBe(wireNames.map(name => `\`${name}\``).join(", ")); + expect(internal.execContext.plainToolWording).toBe(true); + } finally { await transport.close?.(); } + }); + test.each(CLAUDE_MODELS)("recognizes Claude target %s", modelId => { + expect(cursorUsesPlainToolWording(modelId)).toBe(true); + }); + test.each(OTHER_MODELS)("preserves legacy mode for %s", modelId => { + expect(cursorUsesPlainToolWording(modelId)).toBe(false); + }); + test.each(CLAUDE_MODELS)("Claude guidance and redirects contain no concealment: %s", modelId => { + for (const tools of Object.values(legacy.catalogs)) { + const guidance = buildCursorToolGuidanceSystemNote(tools, undefined, modelId)!; + const hint = cursorNativeExecRedirectHint(tools, [], modelId)!; + for (const text of [guidance, hint]) { + expect(text).not.toMatch(CONCEALMENT); + expect(text).toContain("not available"); + expect(text.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + } + } + const codeHint = cursorNativeExecRedirectHint(legacy.catalogs.code, [], modelId)!; + expect(codeHint).toContain("text(await tools.exec_command("); + expect(codeHint).toContain("not top-level tools"); + expect(codeHint).toContain("Every other listed tool remains callable"); + expect(cursorNativeExecRedirectHint(legacy.catalogs.client, [], modelId)).not.toContain("shell_command"); + }); + test.each(OTHER_MODELS)("non-Claude text matches pre-change snapshots byte-for-byte: %s", modelId => { + for (const key of Object.keys(legacy.catalogs) as Array) { + expect(buildCursorToolGuidanceSystemNote(legacy.catalogs[key], undefined, modelId)).toBe(legacy.guidance[key]); + } + expect(cursorNativeExecRedirectHint(legacy.catalogs.code, [], modelId)).toBe(legacy.redirect.code); + expect(cursorNativeExecRedirectHint(legacy.catalogs.client, [], modelId)).toBe(legacy.redirect.client); + expect(cursorNativeExecRedirectHint(undefined, [{ name: "lookup", providerIdentifier: "docs" }], modelId)).toBe(legacy.redirect.mcp); + for (const tools of [undefined, [], legacy.catalogs.shell, legacy.catalogs.mixed, [{ name: "exec" }]]) { + expect(cursorNativeExecRedirectHint(tools, [], modelId)).toBeUndefined(); + } + expect(nativeShellDisabledMessage()).toBe(legacy.defaults.shell); + }); + test("Claude catalog guidance respects tool choice, MCP names, and display caps", () => { + const modelId = CLAUDE_MODELS[0]; + expect(buildCursorToolGuidanceSystemNote(legacy.catalogs.shell, "none", modelId)).toBeUndefined(); + const hint = cursorNativeExecRedirectHint(undefined, [{ name: "lookup", providerIdentifier: "docs" }], modelId)!; + expect(hint).toContain("`mcp_docs_lookup`"); + expect(hint).not.toContain("shell_command"); + const capped = cursorNativeExecRedirectHint(Array.from({ length: 20 }, (_, i) => ({ name: `tool_${i}` })), [], modelId)!; + expect(capped).toContain("`ocx_client_tool_15`"); + expect(capped).not.toContain("`ocx_client_tool_16`"); + expect(capped).toContain("(+4 more)"); + }); + test.each(["code", "shell"] as const)("Claude factual routing prose reaches the next tool call without retry (%s)", async catalog => { + const tools = legacy.catalogs[catalog]; + const toolName = tools[0]!.name; + const fragments = ["Cursor-native Shell is unavailable. ", "I will use the advertised ", `${toolName} tool.\n`]; + const runRequests: CursorRunRequest[] = []; + let reachedToolCall = 0; + const adapter = createCursorAdapter({ adapter: "cursor", apiKey: "fixture-token" }, { + createTransport: () => ({ + async *run(request) { + runRequests.push(request); + for (const text of fragments) yield { type: "text", text } satisfies CursorServerMessage; + reachedToolCall++; + yield { type: "tool_call_start", id: "call-next", name: toolName } satisfies CursorServerMessage; + yield { type: "tool_call_delta", arguments: catalog === "code" ? "text(await tools.exec_command({cmd: 'pwd'}))" : '{"cmd":"pwd"}' } satisfies CursorServerMessage; + yield { type: "tool_call_end", id: "call-next" } satisfies CursorServerMessage; + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + const parsed = parseRequest({ model: "cursor/claude-4.6-opus-high", input: [ + { role: "user", content: "Inspect the project." }, + { type: "function_call", call_id: "call-prior", name: toolName, arguments: "{}" }, + { type: "function_call_output", call_id: "call-prior", output: "prior result" }, + ], tools: tools.map(tool => ({ type: ("freeform" in tool && tool.freeform) ? "custom" : "function", name: tool.name, description: "Fixture tool", parameters: {} })) }); + expect(parsed.context.messages.at(-1)?.role).toBe("toolResult"); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.filter(event => event.type === "error")).toEqual([]); + expect(runRequests).toHaveLength(1); + expect(runRequests[0]!.echoRetryContinuationText).toBeUndefined(); + expect(reachedToolCall).toBe(1); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(fragments.join("")); + expect(events.some(event => event.type === "tool_call_end")).toBe(true); + }); + test("non-Claude factual routing prose retains the legacy abort and corrective retry", async () => { + const runRequests: CursorRunRequest[] = []; + let reachedToolCall = 0; + const adapter = createCursorAdapter({ adapter: "cursor", apiKey: "fixture-token" }, { + createTransport: () => ({ + async *run(request) { + runRequests.push(request); + yield { type: "text", text: "Cursor-native Shell is unavailable. I will use exec.\n" } satisfies CursorServerMessage; + reachedToolCall++; + yield { type: "tool_call_start", id: "call-next", name: "exec" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + const parsed = parseRequest({ model: "cursor/kimi-k3-1m", input: "Inspect the project.", tools: [{ type: "custom", name: "exec", description: "Fixture tool" }] }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(runRequests).toHaveLength(2); + expect(runRequests[1]!.echoRetryContinuationText).toBe(CURSOR_ROUTING_COMMENTARY_RETRY_TEXT); + expect(reachedToolCall).toBe(0); + expect(events.some(event => event.type === "error")).toBe(true); + expect(events.some(event => event.type === "text_delta" || event.type === "tool_call_start")).toBe(false); + }); + test.each(["explicit empty catalog", "empty dispatcher catalog"])("Claude denials accurately report no tools (%s)", async kind => { + const deps: CursorNativeExecContext = kind === "explicit empty catalog" + ? { plainToolWording: true, nativeExecRedirectHint: cursorNativeExecRedirectHint([], [], CLAUDE_MODELS[0]) } + : { plainToolWording: true, clientToolDefs: [], mcpToolDefs: [] }; + for (const frame of Object.values(frames)) { + for (const text of refusalTexts(await handleCursorNativeExec(frame, deps))) { + expect(text).toContain("No client tools are available"); + expect(text).toContain("Answer without tools"); + expect(text).toContain("report"); + expect(text).not.toMatch(/shell_command|exec_command|apply_patch|curl|wget|tool call/i); + expect(text).not.toMatch(CONCEALMENT); + } + } + }); + test("Claude fallback uses the dispatcher catalog without inventing a shell tool", async () => { + const deps: CursorNativeExecContext = { plainToolWording: true, clientToolDefs: [], mcpToolDefs: [{ name: "lookup", providerIdentifier: "docs" } as NonNullable[number]] }; + const [text] = refusalTexts(await handleCursorNativeExec(frames.fetch, deps)); + expect(text).toContain("`mcp_docs_lookup`"); + expect(text).not.toMatch(/shell_command|exec_command|curl|wget/); + expect(text!.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + }); + test("all Claude fs/shell/fetch policy denials use factual wording, including fallback dispatch", async () => { + let fetchCalls = 0; + for (const tools of [undefined, ...Object.values(legacy.catalogs)]) { + const deps: CursorNativeExecContext = { + plainToolWording: true, + nativeExecRedirectHint: cursorNativeExecRedirectHint(tools, [], CLAUDE_MODELS[0]), + fetch: async () => { fetchCalls++; return new Response("unexpected"); }, + }; + for (const frame of Object.values(frames)) { + for (const text of refusalTexts(await handleCursorNativeExec(frame, deps))) { + expect(text).not.toMatch(CONCEALMENT); + expect(text).toContain("not available"); + if (tools?.length) expect(text.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + else expect(text).toContain("Answer without tools"); + } + } + } + expect(fetchCalls).toBe(0); + }); + test("default non-Claude fs/shell/fetch denials are byte-identical", async () => { + const expected = { ...legacy.defaults, read: legacy.defaults.fs, ls: legacy.defaults.fs, grep: legacy.defaults.fs, + stream: legacy.defaults.shell, background: legacy.defaults.shell, stdin: legacy.defaults.shell }; + for (const [key, frame] of Object.entries(frames)) { + for (const text of refusalTexts(await handleCursorNativeExec(frame))) { + expect(text).toBe(expected[key as keyof typeof expected]); + } + } + }); + test.each([false, true])("mutation redirects preserve non-Claude snapshots and remove concealment for Claude (structured=%s)", async structuredEditAvailable => { + for (const operation of ["write", "delete"] as const) { + const deps = { unsafeAllowNativeLocalExec: true, rejectNativeFileMutations: true, structuredEditAvailable }; + const [original] = refusalTexts(await handleCursorNativeExec(frames[operation], deps)); + expect(original).toBe(legacy.mutation[structuredEditAvailable ? "true" : "false"][operation]); + const [plain] = refusalTexts(await handleCursorNativeExec(frames[operation], { ...deps, plainToolWording: true })); + expect(plain).not.toMatch(CONCEALMENT); + expect(plain).toContain("No file was changed."); + expect(plain!.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + } + }); + test("foreground native shells retain factual redirects even with unsafe native exec enabled", async () => { + for (const key of ["shell", "stream"] as const) { + const texts = refusalTexts(await handleCursorNativeExec(frames[key], { unsafeAllowNativeLocalExec: true, plainToolWording: true })); + for (const text of texts) { + expect(text).not.toMatch(CONCEALMENT); + expect(text).toContain("not available"); + expect(text).toContain("Answer without tools"); + expect(text).not.toMatch(/shell_command|exec_command/); + } + } + }); + test.each(["claude-sonnet-5-5", "cursor-grok-4.6-high"])("encoded system roots use the final target model: %s", modelId => { + for (const tools of [legacy.catalogs.shell, legacy.catalogs.code]) { + const request = createCursorRequest(parseRequest({ model: `cursor/${modelId}`, input: "Inspect the project.", + tools: tools.map(tool => ({ type: ("freeform" in tool && tool.freeform) ? "custom" : "function", name: tool.name, description: "Fixture tool", parameters: {} })) })); + const decoded = fromBinary(AgentClientMessageSchema, encodeCursorRunRequest(request)); + if (decoded.message.case !== "runRequest") throw new Error("Expected runRequest"); + const roots = decoded.message.value.conversationState!.rootPromptMessagesJson.map(id => JSON.parse(cursorBlobTextForEstimate(id)!)); + const expected = buildCursorToolGuidanceSystemNote(request.tools, request.toolChoice, request.modelId)!; + expect(roots.some(root => root.role === "system" && root.content === expected)).toBe(true); + if (cursorUsesPlainToolWording(modelId)) expect(expected).not.toMatch(CONCEALMENT); + else expect(expected).toMatch(CONCEALMENT); + } + }); + test("live transport refreshes wording with each turn's target rather than retaining a Claude flag", async () => { + const transport = createLiveCursorTransport({ provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "fixture-token" }, + translatorBudget: createTestTranslatorBudget(), headers: new Headers() }); + const internal = transport as unknown as { open(): void; execContext: CursorNativeExecContext }; + internal.open = () => { throw new Error("wording fixture stops before network"); }; + try { + for (const modelId of ["claude-sonnet-5-5", "cursor-grok-4.6-high", "claude-4.6-opus-high"]) { + const request = { modelId, conversationId: "wording-fixture", system: [], messages: [{ role: "user" as const, content: "Inspect the project." }], tools: legacy.catalogs.shell.map(tool => ({ ...tool, description: "Run", parameters: {} })) }; + await expect(transport.run(request)[Symbol.asyncIterator]().next()).rejects.toThrow("wording fixture stops before network"); + expect(internal.execContext.plainToolWording).toBe(cursorUsesPlainToolWording(modelId)); + expect(internal.execContext.nativeExecRedirectHint).toBe(cursorNativeExecRedirectHint(request.tools, [], modelId)); + const [text] = refusalTexts(await handleCursorNativeExec(frames.fetch, internal.execContext)); + if (cursorUsesPlainToolWording(modelId)) expect(text).not.toMatch(CONCEALMENT); + else expect(text).toBe(legacy.defaults.fetch); + } + } finally { await transport.close?.(); } + }); +});