diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index a9639640055..8e325ae8a29 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,4 +1,5 @@ { + "cursor-auto-tool-result-replay.test.ts": "providers/cursor", "provider-alias-template.test.ts": "providers", "claude-chat-gateway-tool-schema.test.ts": "providers", "provider-anthropic-usage-windows.test.ts": "providers", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 0ef8fd7f57a..650ae58e2f1 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -5,7 +5,7 @@ import { decodeCursorJwtPayload } from "../oauth/cursor"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; -import { cursorCheckpointModelAffinityId, cursorNeedsExternalToolContinuation, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; +import { cursorCheckpointModelAffinityId, cursorReplaysToolResultInRoot, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { @@ -310,11 +310,12 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const echoSniffer = armEchoSniffer ? new CursorEnvelopeEchoSniffer() : undefined; // Mid-stream observer (devlog 260828 F1/F2): diagnostic-only; armed with the // prefix sniffer because both fire on flattened tool-result replay priming. - // The composer-2.5 builds are native-wire but also replay the tool result as root text - // (cursorNeedsExternalToolContinuation), so the client-visible strip and the next-turn - // remint follow that predicate. The prefix retry above stays external-only: its - // corrective continuation text is encoded for external wire models alone. - const armMidstreamEcho = cursorNeedsExternalToolContinuation(activeRequest.modelId) && replaysToolResult; + // The composer-2.5 builds and Auto are native-wire but also replay the tool result as root + // text (cursorReplaysToolResultInRoot), so the client-visible strip and the next-turn + // remint follow that predicate; Auto's #2317 envelope echo is why it is armed there. The + // prefix retry above stays external-only: its corrective continuation text is encoded for + // external wire models alone. + const armMidstreamEcho = cursorReplaysToolResultInRoot(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. diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 50c412c6f3a..c646b269640 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -286,6 +286,23 @@ export function cursorNeedsExternalToolContinuation(modelId: string): boolean { return wire === "composer-2.5" || wire === "composer-2.5-fast"; } +/** + * Models whose tool results are replayed as model-visible root text: every external-continuation + * model, plus the Auto router (wire `default`, any routing level), which still continues with a + * bare resumeAction. + * + * Auto builds its prompt from `rootPromptMessagesJson`; with the result only on `turns[]` it + * asked to run the same successful command again instead of answering (#8538). Replaying it once + * taught Auto to echo the `[Tool Result]` envelope as chat (#2317), so everything gated on this + * predicate also arms the mid-stream envelope strip and next-turn remint (src/adapters/cursor.ts). + * Checkpoint retention keeps {@link cursorNeedsExternalToolContinuation}: Auto's checkpoint still + * carries the pending tool state that full replay does not rebuild. + */ +export function cursorReplaysToolResultInRoot(modelId: string): boolean { + if (cursorNeedsExternalToolContinuation(modelId)) return true; + return stripCursorEffortSuffix(cursorCodexToWireModelId(modelId).trim().toLowerCase()) === CURSOR_AUTO_WIRE_MODEL_ID; +} + function stripCursorEffortSuffix(wireModelId: string): string { const suffixes = [...CANONICAL_EFFORT_SUFFIXES].sort((a, b) => b.length - a.length); for (const suffix of suffixes) { diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 09793782946..d031af78c89 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -6,7 +6,7 @@ import { namespacedToolName } from "../../types"; import type { CursorRunRequest } from "./types"; import { cursorStructuredOutputInstructions } from "./structured-output"; import { decodeCursorCallId } from "./call-id"; -import { cursorCheckpointModelAffinityId, cursorNeedsExternalToolContinuation, isCursorExternalWireModel } from "./discovery"; +import { cursorCheckpointModelAffinityId, cursorNeedsExternalToolContinuation, cursorReplaysToolResultInRoot, isCursorExternalWireModel } from "./discovery"; import { stripAssistantEchoedToolEnvelope } from "./envelope-echo"; import { normalizeCursorToolResultText } from "./tool-result-normalize"; import { debugProviderDiagnostic } from "../../lib/debug"; @@ -247,9 +247,9 @@ function assistantRootText( // so prior history must be replayed here or a ResumeAction has nothing model-visible to continue from. // The active user message is excluded because it travels in the action. When the continuation cannot // rely on native MCP turn state, tool results stay assistant-role text with a [Tool Result] / -// [Tool Error] marker so Cursor does not wrap them as `` (#1992). Native resume models -// already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto -// few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID. +// [Tool Error] marker so Cursor does not wrap them as `` (#1992). Other native resume +// models carry the paired MCP result on turns[] only; Auto gets the root text too (#8538) with the +// echo strip armed (cursorReplaysToolResultInRoot). Each entry is a SHA-256 blob ID. function rootPromptMessages( request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken, @@ -321,7 +321,7 @@ function rootPromptMessages( } const externalModel = isCursorExternalWireModel(request.modelId); - const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId); + const echoToolResultInRoot = cursorReplaysToolResultInRoot(request.modelId); // Replayed results name the invocation that produced them; without it the result is orphaned // (devlog 260829 000_rca). Indexed once per request rather than rescanned per result. const replayedCalls = echoToolResultInRoot ? (knownCalls ?? toolCallsByCallId(messages)) : undefined; @@ -423,9 +423,9 @@ function rootPromptMessages( // "[Tool Result]" envelope below, which carries the same information without a mimickable // call template (devlog 260829 002_audit_round2). } else if (message.role === "toolResult") { - // Native resume models already receive the paired MCP result through turns[]. Replaying - // the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto - // to echo that envelope as chat instead of continuing from the structured result. + // Other native resume models receive the paired MCP result through turns[] only; Auto needs + // the text as well (#8538), and its envelope echo (#2317) is stripped and reminted in + // src/adapters/cursor.ts under the same predicate. if (!echoToolResultInRoot) continue; // #1920: the prefix must reflect the NORMALIZED error state (an empty // node_repl result is an error even when the runtime said isError=false). diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 9a37a0294b4..5465177eceb 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -261,9 +261,16 @@ replay of available raw history; the same limits and final overflow error still Token estimation includes retained external root blobs, including checkpoint-carried roots. Missing or invalid UTF-8 blobs are skipped with bounded provider diagnostics; estimating does not alter blob-retention metrics. -Root-echo eligibility is `cursorNeedsExternalToolContinuation`, which includes native -`composer-2.5` and `composer-2.5-fast`, not only external wire models, so the restoration reaches -every replay that carries an invocation line. Coverage lives in +Root-echo eligibility is `cursorReplaysToolResultInRoot` (`src/adapters/cursor/discovery.ts`): every +`cursorNeedsExternalToolContinuation` model, which includes native `composer-2.5` and +`composer-2.5-fast`, plus the Auto router (wire `default`, any routing level), so the restoration +reaches every replay that carries an invocation line. Auto keeps its bare `resumeAction`, but +without the result in root text it asked to run the same successful command again instead of +answering (#8538); that reverses the #2318 omission, whose envelope echo the filter below now +catches. Continuation action selection and checkpoint retention stay on +`cursorNeedsExternalToolContinuation`: Auto's checkpoint carries pending tool state that full +replay does not rebuild, so elided root text must not discard it. +`tests/providers/cursor/cursor-auto-tool-result-replay.test.ts` covers Auto. Coverage lives in `tests/providers/cursor/cursor-tool-result-invocation.test.ts`. ## Cursor executable tool schema ownership @@ -383,7 +390,7 @@ replay scrub keeps a marker line only inside a fence that closes later in the st (`closedFenceLines`), and the remint follows the filter's verdict (a confirmed echo, or a fenced marker released by hold overflow): `CursorMidstreamEchoObserver` findings are diagnostics only. The filter and the next-turn remint are armed for every model that replays tool results as root text -(`cursorNeedsExternalToolContinuation`, which includes the native-wire composer-2.5 builds); +(`cursorReplaysToolResultInRoot`, which includes the native-wire composer-2.5 builds and Auto); the opening-bytes prefix retry stays external-only, because its corrective continuation text is encoded for external wire models alone. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 96a958e6a5b..ad38c590f16 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -823,6 +823,7 @@ "cursor-stream-health.test.ts": "providers/cursor", "cursor-structured-edit.test.ts": "providers/cursor", "cursor-tool-arg-decoding.test.ts": "providers/cursor", "cursor-tool-choice.test.ts": "providers/cursor", "cursor-tool-continuation.test.ts": "providers/cursor", "cursor-tool-definitions.test.ts": "providers/cursor", + "cursor-auto-tool-result-replay.test.ts": "providers/cursor", "cursor-tool-finalize-race.test.ts": "providers/cursor", "cursor-tool-result-image.test.ts": "providers/cursor", "cursor-freeform-args.test.ts": "providers/cursor", "cursor-tool-result-invocation.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-auto-tool-result-replay.test.ts b/tests/providers/cursor/cursor-auto-tool-result-replay.test.ts new file mode 100644 index 00000000000..808e34dcec6 --- /dev/null +++ b/tests/providers/cursor/cursor-auto-tool-result-replay.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { createCursorAdapter as createCursorAdapterProduction } from "../../../src/adapters/cursor"; +import { cursorNeedsExternalToolContinuation, cursorReplaysToolResultInRoot } from "../../../src/adapters/cursor/discovery"; +import { handleCursorNativeKv } from "../../../src/adapters/cursor/native-exec"; +import { encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { clearCursorEnvelopeEchoRemintForTests, clearCursorThreadContinuityForTests } from "../../../src/adapters/cursor/thread-continuity"; +import { AgentClientMessageSchema, GetBlobArgsSchema, KvServerMessageSchema } from "../../../src/adapters/cursor/gen/agent_pb"; +import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +// #8538: after a successful client tool call, Cursor Auto asked to run the same command again +// instead of answering. Auto continues with a bare resumeAction, and its model-visible prompt +// (rootPromptMessagesJson) carried no trace of the result. The reporter's controlled comparison +// isolated that: replaying the result as root text made Auto answer, the action alone did not. + +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 decode(bytes: Uint8Array): { roots: string; action: string | undefined } { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const ids = run?.conversationState?.rootPromptMessagesJson ?? []; + return { + roots: JSON.stringify(ids.map(id => JSON.parse(new TextDecoder().decode(blobData(id))))), + action: run?.action?.action.case, + }; +} + +const rawMessages: OcxMessage[] = [ + { role: "user", content: "Run hostname exactly once, report it, then finish.", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: { cmd: "hostname" } }], + }, + { role: "toolResult", toolCallId: "call_1", toolName: "exec", content: "build-host-17", isError: false, timestamp: 3 }, +]; + +describe("Cursor Auto tool continuation replays the result as model-visible text", () => { + test.each(["auto", "default", "auto-intelligence"])("%s carries the result and its call in root replay and keeps resumeAction", modelId => { + const { roots, action } = decode(encodeCursorRunRequest({ + modelId, + conversationId: `c-${modelId}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "build-host-17" }], + rawMessages, + })); + expect(roots).toContain("build-host-17"); + expect(roots).toContain("call_1"); + expect(roots).toContain("Run hostname exactly once"); + expect(action).toBe("resumeAction"); + }); + + test("the replay predicate covers the Auto router without moving its continuation action", () => { + for (const modelId of ["auto", "cursor/auto", "default", "auto-intelligence", "composer-2.5", "composer-2.5-fast", "grok-4.7"]) { + expect(cursorReplaysToolResultInRoot(modelId)).toBe(true); + } + for (const modelId of ["composer-1", "composer-2"]) expect(cursorReplaysToolResultInRoot(modelId)).toBe(false); + for (const modelId of ["auto", "default", "auto-intelligence"]) expect(cursorNeedsExternalToolContinuation(modelId)).toBe(false); + }); +}); + +const createCursorAdapter = (...args: Parameters) => + withTestTranslatorBudget(createCursorAdapterProduction(...args)); +const provider: OcxProviderConfig = { adapter: "cursor", baseUrl: "https://api2.cursor.sh" }; + +// #2317 is why Auto stopped seeing this text: it copied the envelope into chat. The mid-stream strip +// and next-turn remint that guard composer-2.5 must therefore guard Auto as well. +test("cursor/auto strips a mid-message envelope and rotates the conversation", async () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const seen: string[] = []; + const factory = () => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + yield { type: "text", text: "The hostname is build-host-17.\n[Tool " } satisfies CursorServerMessage; + yield { type: "text", text: "Result]\nname: exec\noutput: build-host-17\n" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const body = { + modelId: "cursor/auto", + context: { messages: rawMessages }, + stream: false, + options: {}, + _clientThreadId: "auto-echo-thread", + _cursorIdentityScope: "acct-auto-echo", + _cursorConversationId: undefined, + } as OcxParsedRequest; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("The hostname is build-host-17.\n"); + expect(body._cursorConversationId).toBeDefined(); + expect(body._cursorConversationId).not.toBe(seen[0]); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); +}); + +test("cursor/auto withholds a reply that starts with the envelope (#2317) and rotates the conversation", async () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const seen: string[] = []; + const factory = () => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + yield { type: "text", text: "[Tool Result]\n[tool_result]\ncall_id: call_1\nname: exec\n" } satisfies CursorServerMessage; + yield { type: "text", text: "output:\nbuild-host-17\n\nThe hostname is build-host-17." } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const body = { + modelId: "cursor/auto", + context: { messages: rawMessages }, + stream: false, + options: {}, + _clientThreadId: "auto-prefix-echo-thread", + _cursorIdentityScope: "acct-auto-prefix-echo", + _cursorConversationId: undefined, + } as OcxParsedRequest; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + // The live filter withholds the rest of a turn once a marker line matches, exactly as for + // composer-2.5: nothing of the envelope reaches the client, and the next turn starts fresh. + expect(text).toBe(""); + expect(body._cursorConversationId).toBeDefined(); + expect(body._cursorConversationId).not.toBe(seen[0]); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); +}); diff --git a/tests/providers/cursor/cursor-blob.test.ts b/tests/providers/cursor/cursor-blob.test.ts index 1f3c57ed479..d20652cd717 100644 --- a/tests/providers/cursor/cursor-blob.test.ts +++ b/tests/providers/cursor/cursor-blob.test.ts @@ -967,7 +967,7 @@ describe("Cursor blob handshake", () => { expect(serialized).not.toContain("[tool_result]"); }); - test("native Auto Intelligence omits assistant-role [Tool Result] root replay", () => { + test("native Auto Intelligence keeps resumeAction and the paired MCP turn, and replays the result as root text (#8538)", () => { const bytes = encodeCursorRunRequest({ modelId: "auto-intelligence", conversationId: "c-auto-intel", @@ -989,9 +989,9 @@ describe("Cursor blob handshake", () => { expect(run?.action?.action.case).toBe("resumeAction"); const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; const serialized = JSON.stringify(roots); - expect(roots.some(root => root.role === "assistant")).toBe(false); - expect(serialized).not.toContain("[Tool Result]"); - expect(serialized).not.toContain("[tool_result]"); + expect(roots.some(root => root.role === "assistant")).toBe(true); + expect(serialized).toContain("[Tool Result]"); + expect(serialized).toContain("contents"); expect(serialized).toContain("read a file"); const turnIds = run?.conversationState?.turns ?? []; expect(turnIds).toHaveLength(1); diff --git a/tests/providers/cursor/cursor-tool-continuation.test.ts b/tests/providers/cursor/cursor-tool-continuation.test.ts index 296b0b3157a..5b42c64103b 100644 --- a/tests/providers/cursor/cursor-tool-continuation.test.ts +++ b/tests/providers/cursor/cursor-tool-continuation.test.ts @@ -57,10 +57,12 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = expect(serialized).toContain("read a file"); }); + // The Auto router replays the result as root text since #8538 (cursor-auto-tool-result-replay.test.ts); + // other native resume models still rely on turns[] alone. test("native resume models keep tool results on turns[], not as assistant-role root text", () => { const bytes = encodeCursorRunRequest({ - modelId: "auto-intelligence", - conversationId: "c-auto", + modelId: "composer-2", + conversationId: "c-composer-2", system: ["You are helpful."], messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], rawMessages,