From 6dbd35206bb304ab25a62ed6cd6fa9541c626d3c Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 18 Sep 2026 04:01:29 +0900 Subject: [PATCH 1/5] fix(cursor): stop grok-4.6 tool-result echo from poisoning later turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok-4.6 through Cursor often writes a real sentence and then pastes the replayed [Tool Result] envelope. The prefix sniffer only watches the opening bytes of a turn, so the echo reaches Codex, is stored as assistant text, and the next turn replays it — which primes the model to echo again. Strip whole-line echo envelopes from assistant root replay, remint the conversation for the next turn after a mid-stream echo on its own bounded allowance, prefer the retained thread remint override over a stale stored conversation id, and name Write as an unavailable neighboring-agent tool. The current send is never retried: the echo has already reached the client, and resending would be an uncertain replay. Conversations already poisoned still need a new task. Carries the work in #4900 onto current dev. That branch holds a pre-squash copy of #4875, which landed as ee2883316095174824cfd2b02f569cbee7cd8ae0 with review hardening the copy predates, so dev's version is authoritative for every shared file and only the increment is reapplied here. Co-authored-by: MerryEcho --- src/adapters/cursor.ts | 43 +++++- src/adapters/cursor/envelope-echo.ts | 57 +++++++- src/adapters/cursor/protobuf-request.ts | 13 +- src/adapters/cursor/request-builder.ts | 12 +- src/adapters/cursor/thread-continuity.ts | 136 ++++++++++++++---- src/adapters/cursor/tool-guidance.ts | 9 +- structure/providers/cursor.md | 32 +++++ .../cursor/cursor-envelope-echo-retry.test.ts | 108 ++++++++++++++ .../cursor/cursor-request-builder.test.ts | 43 ++++++ 9 files changed, 408 insertions(+), 45 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index a8b518b90a..65f4393877 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -34,9 +34,12 @@ import { estimateTokens } from "../lib/token-estimate"; import { clearCursorIncompleteToolRemint, cursorIncompleteToolRemintScopeKey, + clearCursorEnvelopeEchoRemint, + cursorEnvelopeEchoRemintScopeKey, cursorOverflowRemintScopeKey, markCursorOverflowSurfaced, recordCursorIncompleteToolRemint, + recordCursorEnvelopeEchoRemint, recordCursorOverflowRemint, rememberCursorThreadConversation, shouldSkipCursorOverflowRemint, @@ -206,6 +209,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda let lastTransport: { captured?: Uint8Array } | undefined; let emittedClientTool = false; let sawIncompleteToolCall = false; + let sawMidstreamEnvelopeEcho = false; // Ordering proof for tool-suspended checkpoints: true only when the newest captured // checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream // serialized its suspended-on-tool-call state. Only that snapshot can safely resume @@ -390,7 +394,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } if (event.type !== "heartbeat") emittedOutput = true; if (event.type === "done") { - for (const finding of midstreamObserver?.findings() ?? []) { + const midstreamFindings = midstreamObserver?.findings() ?? []; + if (midstreamFindings.length > 0) sawMidstreamEnvelopeEcho = true; + for (const finding of midstreamFindings) { debugProviderDiagnostic("cursor", "midstream-envelope-echo", { wireModel: activeRequest.modelId, conversationHash: activeRequest.conversationId.slice(0, 16), @@ -564,6 +570,41 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) { clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey); } + // A mid-stream envelope echo has ALREADY reached the client — the prefix sniffer only + // watches the first bytes of a turn, and grok-4.6 writes a real sentence before pasting + // the envelope. It cannot be quarantined, so the recovery is the same as the + // incomplete-tool case: leave this turn alone and rotate the next turn's id, otherwise + // the stored echo is replayed and primes the model to echo again. + // + // Its own budget, not the incomplete-tool one: echoing is cheap and repeatable while an + // incomplete client-tool stream is rare and structural, so a shared counter would let a + // persistently echoing model spend the allowance the other recovery needs. Skipped when + // the incomplete-tool arm already reminted this turn — one rotation is enough. + const envelopeEchoRemintScopeKey = + _parsed._cursorIsolateConversation !== true + && request.contextUsageStoreCheckpoints !== false + ? cursorEnvelopeEchoRemintScopeKey( + cursorClientThreadOwner(_parsed), + _parsed._cursorIdentityScope, + ) + : null; + if (sawMidstreamEnvelopeEcho && !sawIncompleteToolCall && envelopeEchoRemintScopeKey) { + if (recordCursorEnvelopeEchoRemint(envelopeEchoRemintScopeKey)) { + if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); + debugProviderDiagnostic("cursor", "midstream-envelope-echo-remint", { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }); + remintConversationId(request.conversationId); + } else { + debugProviderDiagnostic("cursor", "midstream-envelope-echo-remint-exhausted", { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }); + } + } else if (!sawMidstreamEnvelopeEcho && completedNormally && envelopeEchoRemintScopeKey) { + clearCursorEnvelopeEchoRemint(envelopeEchoRemintScopeKey); + } if ( request.checkpointInvalidationReason && request.checkpointInvalidationReason !== "missing_ref" diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index 336a276793..4691731fa0 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -13,6 +13,58 @@ */ const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const; + +function isEchoMarkerLine(line: string): boolean { + return (ECHO_MARKERS as readonly string[]).includes(line.replace(/^[ \t]+/, "")); +} + +/** + * Drop echoed tool-result envelopes from assistant history before Cursor root replay. + * + * The prefix sniffer catches an echo that STARTS a turn, but grok-4.6 routinely writes a real + * sentence first and pastes the envelope after it. That text has already reached the client and + * is stored as assistant output, so replaying it verbatim re-primes the next turn with the very + * envelope the model is copying. + * + * Scope starts AT the marker line and runs to the next blank line, rather than to the end of + * the message. The echoed envelope has no terminator we can recognise — we build it as a marker + * line plus arbitrary result text (protobuf-request.ts), and the observed copies are not + * byte-exact, so matching against the replayed envelope is not available either. Truncating to + * the end of the message was the alternative, and it discards a genuine answer whenever the + * model resumes after the echo. A blank line is the one boundary the model reliably writes when + * it goes back to prose. + * + * The tradeoff is explicit: an echoed envelope whose pasted result itself contains a blank line + * leaves its remainder in replay. That is the safer direction to be wrong in — conversation + * remint, not this filter, is the primary defence against a poisoned conversation, and this only + * stops the transcript from feeding itself. + * + * Only whole-line markers count, so prose such as "the string [Tool Result] appeared" survives. + */ +export function stripAssistantEchoedToolEnvelope(text: string): string { + if (!text || !ECHO_MARKERS.some(marker => text.includes(marker))) return text; + const newline = text.includes("\r\n") ? "\r\n" : "\n"; + const lines = text.split(/\r?\n/); + const kept: string[] = []; + let dropped = false; + let index = 0; + while (index < lines.length) { + const line = lines[index] ?? ""; + if (!isEchoMarkerLine(line)) { + kept.push(line); + index += 1; + continue; + } + dropped = true; + index += 1; + // The envelope body is the contiguous non-blank run after the marker. The blank line that + // ends it is left in place, so surviving prose on either side stays separated. + while (index < lines.length && (lines[index] ?? "").trim() !== "") index += 1; + } + if (!dropped) return text; + return kept.join(newline).trimEnd(); +} + const MAX_SNIFF_BYTES = 40; /** Mid-stream observer: max leading whitespace on a line before matching disarms. */ const MAX_MIDSTREAM_LINE_INDENT = 128; @@ -66,8 +118,9 @@ export interface MidstreamEchoFinding { * MIDDLE of an agent message — after legitimate leading text — one of them * carrying a whitespace-spliced call-id ("fc_x mar-y" instead of "fc_x-y"). * Deltas at that point have already reached the client, so this observer - * never throws and never withholds output: it records findings so the - * adapter can emit a structured diagnostic at turn end. Only fixed marker + * never throws and never withholds output. It records findings so the adapter + * can emit a structured diagnostic and remint the conversation for the next + * turn at turn end. Only fixed marker * enums, numeric offsets, and corruption booleans are retained — never * content bytes. */ diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index b33c651838..894cf3eae4 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -6,6 +6,7 @@ import { namespacedToolName } from "../../types"; import type { CursorRunRequest } from "./types"; import { decodeCursorCallId } from "./call-id"; import { cursorNeedsExternalToolContinuation, isCursorExternalWireModel } from "./discovery"; +import { stripAssistantEchoedToolEnvelope } from "./envelope-echo"; import { normalizeCursorToolResultText } from "./tool-result-normalize"; import { debugProviderDiagnostic } from "../../lib/debug"; import { @@ -208,11 +209,13 @@ function assistantRootText( message: Extract, includeThinking: boolean, ): string { - if (typeof message.content === "string") return message.content; - return message.content - .map(part => (part.type === "text" ? part.text : includeThinking && part.type === "thinking" ? part.thinking : undefined)) - .filter((value): value is string => typeof value === "string" && value.length > 0) - .join("\n"); + const raw = typeof message.content === "string" + ? message.content + : message.content + .map(part => (part.type === "text" ? part.text : includeThinking && part.type === "thinking" ? part.thinking : undefined)) + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join("\n"); + return stripAssistantEchoedToolEnvelope(raw); } // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata), diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 51651bb07a..689038cbec 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -340,7 +340,13 @@ export function cursorConversationIdFromClientThread(threadId: string, identityS /** * Resolve the Cursor conversation id for this turn. - * Priority: force-fresh → isolate helper → remembered → client thread owner → random. + * Priority: force-fresh → isolate helper → thread remint override → stored conversation id + * → client thread hash → random. + * + * The remint override must beat a stored `_cursorConversationId`. Only the remint path writes + * the thread store (cursor.ts), so a stored id that disagrees with it is the pre-remint value, + * and preferring it let a second Responses chain in the same Codex thread keep resuming the + * conversation the previous turn just rotated away from. * Never use OpenAI Responses `previous_response_id` (resp_*) or shared `prompt_cache_key` * (cache-cohort fingerprint, not conversation ownership). */ @@ -351,11 +357,13 @@ export function resolveCursorConversationId( ): string { if (options.forceFreshConversation === true) return generatedCursorConversationId(); if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId(); - if (parsed._cursorConversationId) return parsed._cursorConversationId; const threadId = cursorClientThreadOwner(parsed); if (threadId) { const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope); if (recovered) return recovered; + } + if (parsed._cursorConversationId) return parsed._cursorConversationId; + if (threadId) { return cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope); } return generatedCursorConversationId(); diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index ed29fdc88a..72778ce4c3 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -169,21 +169,65 @@ type IncompleteToolRemintState = { updatedAt: number; }; -const incompleteToolRemintByScope = new Map(); +/** + * One bounded next-turn remint allowance, keyed by retained thread scope. + * + * Each recovery reason owns its own instance. Sharing one budget would let a cheap, frequent + * failure spend the allowance that a rarer, more expensive recovery depends on. + */ +function createCursorRemintBudget(max: number, ttlMs: number, maxEntries: number) { + const byScope = new Map(); -function pruneIncompleteToolRemints(at: number): void { - for (const [scopeKey, entry] of incompleteToolRemintByScope) { - if (at - entry.updatedAt > CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS) { - incompleteToolRemintByScope.delete(scopeKey); + const prune = (at: number): void => { + for (const [scopeKey, entry] of byScope) { + if (at - entry.updatedAt > ttlMs) byScope.delete(scopeKey); } - } - while (incompleteToolRemintByScope.size > CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES) { - const oldest = incompleteToolRemintByScope.keys().next().value; - if (oldest === undefined) break; - incompleteToolRemintByScope.delete(oldest); - } + while (byScope.size > maxEntries) { + const oldest = byScope.keys().next().value; + if (oldest === undefined) break; + byScope.delete(oldest); + } + }; + + return { + /** Record one remint; returns false when this budget is exhausted. */ + record(scopeKey: string): boolean { + const at = now(); + prune(at); + const existing = byScope.get(scopeKey); + if (existing && existing.remintCount >= max) { + existing.updatedAt = at; + byScope.delete(scopeKey); + byScope.set(scopeKey, existing); + return false; + } + const entry = existing ?? { remintCount: 0, updatedAt: at }; + entry.remintCount += 1; + entry.updatedAt = at; + byScope.delete(scopeKey); + byScope.set(scopeKey, entry); + prune(at); + return true; + }, + clear(scopeKey: string): void { + byScope.delete(scopeKey); + }, + clearForTests(): void { + byScope.clear(); + }, + countForTests(): number { + prune(now()); + return byScope.size; + }, + }; } +const incompleteToolRemintBudget = createCursorRemintBudget( + CURSOR_INCOMPLETE_TOOL_REMINT_MAX, + CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS, + CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES, +); + /** Incomplete-tool and overflow recovery share ownership scope, but keep independent budgets. */ export function cursorIncompleteToolRemintScopeKey( threadOwner: string | undefined, @@ -194,34 +238,64 @@ export function cursorIncompleteToolRemintScopeKey( /** Record one incomplete-tool remint; returns false when the independent cap is exhausted. */ export function recordCursorIncompleteToolRemint(scopeKey: string): boolean { - const at = now(); - pruneIncompleteToolRemints(at); - const existing = incompleteToolRemintByScope.get(scopeKey); - if (existing && existing.remintCount >= CURSOR_INCOMPLETE_TOOL_REMINT_MAX) { - existing.updatedAt = at; - incompleteToolRemintByScope.delete(scopeKey); - incompleteToolRemintByScope.set(scopeKey, existing); - return false; - } - const entry = existing ?? { remintCount: 0, updatedAt: at }; - entry.remintCount += 1; - entry.updatedAt = at; - incompleteToolRemintByScope.delete(scopeKey); - incompleteToolRemintByScope.set(scopeKey, entry); - pruneIncompleteToolRemints(at); - return true; + return incompleteToolRemintBudget.record(scopeKey); } /** A clean turn replenishes this recovery without changing the overflow retry budget. */ export function clearCursorIncompleteToolRemint(scopeKey: string): void { - incompleteToolRemintByScope.delete(scopeKey); + incompleteToolRemintBudget.clear(scopeKey); } export function clearCursorIncompleteToolRemintForTests(): void { - incompleteToolRemintByScope.clear(); + incompleteToolRemintBudget.clearForTests(); } export function cursorIncompleteToolRemintCountForTests(): number { - pruneIncompleteToolRemints(now()); - return incompleteToolRemintByScope.size; + return incompleteToolRemintBudget.countForTests(); +} + +/** + * Max next-turn rotations after a MID-STREAM envelope echo, per retained scope. + * + * Deliberately a separate budget from the incomplete-tool allowance. A mid-stream echo is a + * cheap, repeatable formatting failure, while an incomplete client-tool stream is a rarer + * structural one; on a shared counter a model that echoes every turn would spend the budget + * that incomplete-tool recovery depends on. Bounding it at all is the point: the echo has + * already reached the client and cannot be quarantined, so without a cap a persistently + * echoing model would remint the conversation on every single turn, forever. + */ +export const CURSOR_ENVELOPE_ECHO_REMINT_MAX = 3; +export const CURSOR_ENVELOPE_ECHO_REMINT_TTL_MS = CURSOR_OVERFLOW_REMINT_TTL_MS; +export const CURSOR_ENVELOPE_ECHO_REMINT_MAX_ENTRIES = CURSOR_OVERFLOW_REMINT_MAX_ENTRIES; + +const envelopeEchoRemintBudget = createCursorRemintBudget( + CURSOR_ENVELOPE_ECHO_REMINT_MAX, + CURSOR_ENVELOPE_ECHO_REMINT_TTL_MS, + CURSOR_ENVELOPE_ECHO_REMINT_MAX_ENTRIES, +); + +/** Echo recovery shares ownership scope with overflow and incomplete-tool, budget apart. */ +export function cursorEnvelopeEchoRemintScopeKey( + threadOwner: string | undefined, + identityScope?: string, +): string | null { + return cursorOverflowRemintScopeKey(threadOwner, identityScope); +} + +/** Record one envelope-echo remint; returns false when the independent cap is exhausted. */ +export function recordCursorEnvelopeEchoRemint(scopeKey: string): boolean { + return envelopeEchoRemintBudget.record(scopeKey); +} + +/** A turn that completed without an echo replenishes only this budget. */ +export function clearCursorEnvelopeEchoRemint(scopeKey: string): void { + envelopeEchoRemintBudget.clear(scopeKey); +} + +export function clearCursorEnvelopeEchoRemintForTests(): void { + envelopeEchoRemintBudget.clearForTests(); +} + +export function cursorEnvelopeEchoRemintCountForTests(): number { + return envelopeEchoRemintBudget.countForTests(); } diff --git a/src/adapters/cursor/tool-guidance.ts b/src/adapters/cursor/tool-guidance.ts index 87e63730ca..f9801b5eb8 100644 --- a/src/adapters/cursor/tool-guidance.ts +++ b/src/adapters/cursor/tool-guidance.ts @@ -4,13 +4,14 @@ import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EX export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE = 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell.'; -const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const; +const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS", "Write"] as const; const NEIGHBOR_AGENT_TOOL_ALIASES: Record<(typeof NEIGHBOR_AGENT_TOOL_NAMES)[number], readonly string[]> = { Read: ["read", "read_file"], Grep: ["grep"], Glob: ["glob", "find"], Bash: ["bash", "shell"], LS: ["ls"], + Write: ["write", "write_file"], }; export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [ @@ -22,7 +23,7 @@ export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [ "The Cursor bridge may suspend after the first returned bridge tool call, so emit sibling calls together before any result is needed.", "If parallel emission is unavailable, continue with separate shell-bridge calls until the requested count has returned.", "Do not use `tool_search`, external MCP, or resource discovery just to pad the count unless explicitly asked.", - "Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, or `LS` unless this turn's catalog lists those exact names or an equivalent listed client tool.", + "Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, `LS`, or `Write` unless this turn's catalog lists those exact names or an equivalent listed client tool.", ].join(" "); @@ -190,7 +191,7 @@ 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, 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." + ? "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.` @@ -199,7 +200,7 @@ export function buildCursorToolGuidanceSystemNote( ? "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, 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.` + ? `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 ? "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." diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index f290a0edc1..fc8eb5e555 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -164,6 +164,38 @@ An incomplete client-tool stream is fail-closed for the current turn: `finalizeT Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. +## Mid-stream envelope echo + +The prefix sniffer only watches the opening bytes of a turn. An external model that writes real +prose first and then pastes a replayed `[Tool Result]` envelope defeats it, so that text reaches +the client and is stored as assistant output. `CursorMidstreamEchoObserver` records those +findings without throwing or withholding output, and at turn end eligible non-isolated turns +remint the conversation id for the NEXT turn. The current send is never retried: the echo is +already delivered and a resend would be an uncertain replay. + +That rotation has its own bounded allowance in `src/adapters/cursor/thread-continuity.ts`, +separate from the incomplete-tool budget and from the overflow budget. It is bounded because a +model that echoes every turn would otherwise rotate the conversation forever, and it is separate +because echoing is cheap and repeatable while an incomplete client-tool stream is rare and +structural — one shared counter would let the cheap failure spend the allowance the other +recovery depends on. Exhaustion records a `midstream-envelope-echo-remint-exhausted` diagnostic +and keeps the conversation; a turn that completes without an echo clears only this counter. When +an incomplete-tool remint already fired in the same turn, the echo arm does not rotate again. + +Assistant root replay drops echoed envelopes before they are sent back upstream +(`stripAssistantEchoedToolEnvelope`), so the transcript stops feeding itself. The strip starts at +a whole-line marker and ends at the next blank line rather than at the end of the message: the +envelope has no recognisable terminator and observed copies are not byte-exact, and truncating to +the end discarded a genuine answer whenever the model resumed after the echo. An envelope whose +pasted body contains its own blank line therefore leaves a remainder in replay; conversation +remint, not this filter, is the primary defence against a poisoned conversation. + +`resolveCursorConversationId` prefers the retained thread override over a stored +`_cursorConversationId`. Only the remint path writes that store, so a stored id that disagrees +with it is the pre-remint value; preferring it let a second Responses chain in one Codex thread +keep resuming the conversation the previous turn had rotated away from. Isolated helper turns +still bypass both and mint their own id. + Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index abb6e9fde4..daeeb77176 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -7,7 +7,18 @@ import { CursorMidstreamEchoObserver, CursorRoutingCommentarySniffer, MAX_MIDSTREAM_SCAN_LENGTH, + stripAssistantEchoedToolEnvelope, } from "../../../src/adapters/cursor/envelope-echo"; +import { + CURSOR_ENVELOPE_ECHO_REMINT_MAX, + clearCursorEnvelopeEchoRemintForTests, + clearCursorIncompleteToolRemintForTests, + clearCursorThreadContinuityForTests, + cursorEnvelopeEchoRemintScopeKey, + lookupCursorThreadConversation, + recordCursorEnvelopeEchoRemint, + recordCursorIncompleteToolRemint, +} from "../../../src/adapters/cursor/thread-continuity"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; @@ -404,3 +415,100 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(runRequests[1]?.echoRetryContinuationText).toBe(CURSOR_ROUTING_COMMENTARY_RETRY_TEXT); }); }); + +describe("stripAssistantEchoedToolEnvelope", () => { + test("keeps leading commentary and drops the envelope that follows it", () => { + expect(stripAssistantEchoedToolEnvelope( + "20-24 pages are on the board.\n[Tool Result]\n[tool_result]\nname: Write\noutput:\nwrote it\n", + )).toBe("20-24 pages are on the board."); + }); + + test("keeps a real answer written after the echo", () => { + // The whole point of bounding the strip at the blank line: truncating to the end of the + // message would have discarded this answer from every later replay. + expect(stripAssistantEchoedToolEnvelope( + "Checking now.\n[Tool Result]\nname: Read\noutput: 41 rows\n\nThe table has 41 rows.", + )).toBe("Checking now.\n\nThe table has 41 rows."); + }); + + test("does not strip an inline mention of the marker", () => { + const source = "The string [Tool Result] appeared in the transcript I reviewed."; + expect(stripAssistantEchoedToolEnvelope(source)).toBe(source); + }); + + test("drops a prefix-only envelope to empty text", () => { + expect(stripAssistantEchoedToolEnvelope("[Tool Result]\n[tool_result]\ncall_id: 1\n")).toBe(""); + }); +}); + +describe("Cursor midstream envelope-echo remint", () => { + test("rotates the conversation after grok-4.6 copies the envelope mid-message", async () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const seen: string[] = []; + let attempts = 0; + const factory = () => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + attempts += 1; + if (attempts === 1) { + yield { type: "text", text: "I'll write the import script now.\n" } satisfies CursorServerMessage; + yield { type: "text", text: "[Tool Result]\nname: Write\noutput: ok\n" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + return; + } + yield { type: "text", text: "NEXT" } 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 threadId = "midstream-echo-remint-thread"; + const body = { + ...toolResultBody("cursor/grok-4.6"), + _clientThreadId: threadId, + _cursorIdentityScope: "acct-midstream-echo", + _cursorConversationId: undefined, + } as OcxParsedRequest; + + const first: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => first.push(event)); + // The echo already reached the client: it is not withheld, only recovered from. + expect(first.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")) + .toContain("[Tool Result]"); + expect(body._cursorConversationId).toBeDefined(); + expect(body._cursorConversationId).not.toBe(seen[0]); + expect(lookupCursorThreadConversation(threadId, "acct-midstream-echo")).toBe(body._cursorConversationId); + + const second: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => second.push(event)); + expect(attempts).toBe(2); + expect(seen[1]).toBe(body._cursorConversationId); + expect(second.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe("NEXT"); + + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + }); + + test("the echo allowance is bounded and independent of the incomplete-tool allowance", () => { + clearCursorEnvelopeEchoRemintForTests(); + clearCursorIncompleteToolRemintForTests(); + const scopeKey = cursorEnvelopeEchoRemintScopeKey("thread-echo-budget", "acct-echo-budget"); + expect(scopeKey).not.toBeNull(); + + // Bounded: an endlessly echoing model must not rotate the conversation on every turn. + for (let attempt = 0; attempt < CURSOR_ENVELOPE_ECHO_REMINT_MAX; attempt++) { + expect(recordCursorEnvelopeEchoRemint(scopeKey!)).toBe(true); + } + expect(recordCursorEnvelopeEchoRemint(scopeKey!)).toBe(false); + + // Independent: spending the echo budget leaves incomplete-tool recovery its full allowance, + // so a cheap repeated failure cannot starve the rarer structural one. + expect(recordCursorIncompleteToolRemint(scopeKey!)).toBe(true); + + clearCursorEnvelopeEchoRemintForTests(); + clearCursorIncompleteToolRemintForTests(); + }); +}); + diff --git a/tests/providers/cursor/cursor-request-builder.test.ts b/tests/providers/cursor/cursor-request-builder.test.ts index 0e1b355b30..cb87effa14 100644 --- a/tests/providers/cursor/cursor-request-builder.test.ts +++ b/tests/providers/cursor/cursor-request-builder.test.ts @@ -16,6 +16,10 @@ import { CURSOR_TOOL_BYTES_LIMIT, CURSOR_TOOL_COUNT_LIMIT, } from "../../../src/adapters/cursor/request-builder"; +import { + clearCursorThreadContinuityForTests, + rememberCursorThreadConversation, +} from "../../../src/adapters/cursor/thread-continuity"; import { cursorCheckpointModelAffinityId } from "../../../src/adapters/cursor/discovery"; import { cursorMcpToolsEncodedSize } from "../../../src/adapters/cursor/tool-definitions"; import { encodeCursorCallId, resetCursorCallIdProvenanceForTests } from "../../../src/adapters/cursor/call-id"; @@ -79,6 +83,45 @@ describe("Cursor request builder", () => { expect(continuation.conversationId).toBe(initial.conversationId); }); + test("thread remint override wins over a stale stored Cursor conversation id", () => { + // Only the remint path writes the thread store, so a stored id that disagrees with it is + // the pre-remint value. Preferring the stored id let a second Responses chain in the same + // Codex thread keep resuming the conversation the previous turn rotated away from. + clearCursorThreadContinuityForTests(); + rememberCursorThreadConversation("thread-poisoned", "cursor_fresh", "acct-sticky"); + try { + const request = createCursorRequest({ + ...base, + modelId: "cursor/grok-4.6", + _clientThreadId: "thread-poisoned", + _cursorConversationId: "cursor_stale", + _cursorIdentityScope: "acct-sticky", + }); + expect(request.conversationId).toBe("cursor_fresh"); + } finally { + clearCursorThreadContinuityForTests(); + } + }); + + test("isolated helpers ignore the parent thread remint override", () => { + clearCursorThreadContinuityForTests(); + rememberCursorThreadConversation("thread-poisoned", "cursor_fresh", "acct-sticky"); + try { + const request = createCursorRequest({ + ...base, + modelId: "cursor/grok-4.6", + _clientThreadId: "thread-poisoned", + _cursorConversationId: "cursor_parent", + _cursorIdentityScope: "acct-sticky", + _cursorIsolateConversation: true, + }); + expect(request.conversationId).not.toBe("cursor_fresh"); + expect(request.conversationId).not.toBe("cursor_parent"); + } finally { + clearCursorThreadContinuityForTests(); + } + }); + test("uses a Cursor-only Desktop owner without widening Responses replay scope", () => { const a = createCursorRequest({ ...base, From 98f70c9a4937d8b42cfd848a41244a5095cd086b Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 18 Sep 2026 04:05:40 +0900 Subject: [PATCH 2/5] test(cursor): prove the echo strip is reached from root replay The unit test covers the filter; this covers the wiring. It also pins the bounded-strip behaviour end to end: the prose before AND after the echoed envelope survives into rootPromptMessagesJson while the envelope body does not. Placed in cursor-tool-continuation.test.ts because cursor-blob.test.ts sits exactly at its file-size-ratchet cap of 3657 lines and cannot take another line. --- .../cursor/cursor-tool-continuation.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/providers/cursor/cursor-tool-continuation.test.ts b/tests/providers/cursor/cursor-tool-continuation.test.ts index 6196af58da..1c5f58407f 100644 --- a/tests/providers/cursor/cursor-tool-continuation.test.ts +++ b/tests/providers/cursor/cursor-tool-continuation.test.ts @@ -116,6 +116,39 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = expect(serialized).not.toContain("[tool_result]"); expect(serialized).toContain("read a file"); }); + + test("an echoed tool-result envelope in assistant history is dropped from root replay", () => { + // Wiring guard, not a unit test of the filter: grok-4.6 pastes the replayed envelope after + // real prose, that text is stored as assistant output, and replaying it verbatim primes the + // next turn to echo again. The strip has to be reached from the root-replay path to matter. + const echoed: OcxMessage[] = [ + { role: "user", content: "write the script", timestamp: 1 }, + { + role: "assistant", + model: "cursor/grok-4.6", + timestamp: 2, + content: [{ + type: "text", + text: "I wrote the import script.\n[Tool Result]\nname: Write\noutput: ECHOED BODY\n\nIt handles 41 rows.", + }], + }, + ]; + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-echo", + system: ["You are helpful."], + messages: [{ role: "user", content: "keep going" }], + rawMessages: echoed, + }); + const serialized = JSON.stringify(decodeRoots(bytes)); + + expect(serialized).not.toContain("ECHOED BODY"); + expect(serialized).not.toContain("[Tool Result]"); + // The model's own prose on BOTH sides of the echo survives: bounding the strip at the blank + // line is what keeps the answer that follows it. + expect(serialized).toContain("I wrote the import script."); + expect(serialized).toContain("It handles 41 rows."); + }); }); import { create as createPb } from "@bufbuild/protobuf"; From 4c520219ef61ecd1ad1f397c6902d3e965e5385d Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 18 Sep 2026 04:17:25 +0900 Subject: [PATCH 3/5] test(cursor): include Write in the neighboring-agent guidance assertions Adding Write to NEIGHBOR_AGENT_TOOL_NAMES changes the generated guidance note, and these three assertions pin that note verbatim. Line-neutral replacements; no assertion is weakened and the negative cases still hold. --- tests/providers/cursor/cursor-tool-definitions.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index fb15f0e7d6..fb990bc716 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -611,7 +611,7 @@ describe("Cursor tool definitions", () => { expect(note).toContain("`exec_command`"); expect(note).toContain("`mcp__fs__read_file`"); expect(note).toContain("current tool catalog as ground truth"); - expect(note).toContain("This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`"); + expect(note).toContain("This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`, `Write`"); expect(note).toContain("not an external MCP server tool"); expect(note).toContain("NEVER attempt Cursor-native Shell, Read, Grep, List"); expect(note).toContain("`exec_command` is the ONLY shell surface"); @@ -688,7 +688,7 @@ describe("Cursor tool definitions", () => { if (!note) throw new Error("Expected Cursor tool guidance note"); expect(note).toContain("available tool names are exactly `exec_command`, `ocx_client_Glob`"); - expect(note).toContain("This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Bash`, `LS`"); + expect(note).toContain("This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Bash`, `LS`, `Write`"); expect(note).not.toContain("`Read`, `Grep`, `Glob`, `Bash`, `LS`"); }); @@ -705,7 +705,7 @@ describe("Cursor tool definitions", () => { if (!note) throw new Error("Expected Cursor tool guidance note"); expect(note).toContain("available tool names are exactly `exec_command`, `ocx_client_read`, `ocx_client_find`, `ocx_client_bash`"); - expect(note).toContain("This turn does not expose neighboring-agent tool names `Grep`, `LS`"); + expect(note).toContain("This turn does not expose neighboring-agent tool names `Grep`, `LS`, `Write`"); expect(note).not.toContain("`Read`"); expect(note).not.toContain("`Glob`"); expect(note).not.toContain("`Bash`"); From cced7bf867ebd8a0b1e6ec63ac00dbcdf070a7cb Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 18 Sep 2026 04:28:22 +0900 Subject: [PATCH 4/5] fix(cursor): keep a compaction turn off the parent thread override Preferring the retained thread override over a stored _cursorConversationId is right for a stale id from a second Responses chain, but a compaction turn also carries a thread owner and its own conversation id while never setting the isolate flag. Unconditionally preferring the override pulled compaction onto the parent conversation, which "compaction storage isolation preserves the stable thread override without relying on the isolate flag" in cursor-adapter.test.ts exists to prevent. Exclude compaction from the override lookup and pin the interaction with its own case. --- src/adapters/cursor/request-builder.ts | 5 ++++- .../cursor/cursor-request-builder.test.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 689038cbec..3eb922b968 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -358,7 +358,10 @@ export function resolveCursorConversationId( if (options.forceFreshConversation === true) return generatedCursorConversationId(); if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId(); const threadId = cursorClientThreadOwner(parsed); - if (threadId) { + // A compaction turn carries its own conversation id and must not be pulled onto the parent's + // thread override. It is isolated in effect without ever setting the isolate flag, which is why + // the override check has to exclude it explicitly rather than rely on that flag. + if (threadId && parsed._compactionRequest !== true) { const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope); if (recovered) return recovered; } diff --git a/tests/providers/cursor/cursor-request-builder.test.ts b/tests/providers/cursor/cursor-request-builder.test.ts index cb87effa14..845225f9da 100644 --- a/tests/providers/cursor/cursor-request-builder.test.ts +++ b/tests/providers/cursor/cursor-request-builder.test.ts @@ -122,6 +122,27 @@ describe("Cursor request builder", () => { } }); + test("a compaction turn keeps its own conversation id over the parent thread override", () => { + // Compaction never sets the isolate flag, so preferring the thread override unconditionally + // pulled the compaction turn onto the parent conversation. It carries its own id and is + // isolated in effect; the override exists to beat a STALE stored id, not this one. + clearCursorThreadContinuityForTests(); + rememberCursorThreadConversation("thread-compaction", "cursor_parent_stable", "acct-compaction"); + try { + const request = createCursorRequest({ + ...base, + modelId: "cursor/grok-4.6", + _clientThreadId: "thread-compaction", + _cursorConversationId: "cursor_compaction_turn", + _cursorIdentityScope: "acct-compaction", + _compactionRequest: true, + }); + expect(request.conversationId).toBe("cursor_compaction_turn"); + } finally { + clearCursorThreadContinuityForTests(); + } + }); + test("uses a Cursor-only Desktop owner without widening Responses replay scope", () => { const a = createCursorRequest({ ...base, From 9dc23ab656995e7b81d7fd428dccdd6cd1c65a72 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 18 Sep 2026 04:38:10 +0900 Subject: [PATCH 5/5] test(cursor): exercise the echo strip through a real tool continuation Root replay only carries history on a tool-continuation turn, so the previous shape sent a plain user message and produced a system-only root prompt: the assertions could never have seen the assistant text they were checking. Give the turn a tool result so the assistant message is actually replayed. It also now asserts the GENUINE replayed envelope survives. The strip must remove the copy the model pasted into its own text without touching the tool-result envelope the adapter builds. --- tests/providers/cursor/cursor-tool-continuation.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/providers/cursor/cursor-tool-continuation.test.ts b/tests/providers/cursor/cursor-tool-continuation.test.ts index 1c5f58407f..91c55a02ef 100644 --- a/tests/providers/cursor/cursor-tool-continuation.test.ts +++ b/tests/providers/cursor/cursor-tool-continuation.test.ts @@ -132,18 +132,21 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = text: "I wrote the import script.\n[Tool Result]\nname: Write\noutput: ECHOED BODY\n\nIt handles 41 rows.", }], }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", toolNamespace: "mcp__fs", content: "GENUINE RESULT", isError: false, timestamp: 3 }, ]; const bytes = encodeCursorRunRequest({ modelId: "composer-2.5", conversationId: "c-echo", system: ["You are helpful."], - messages: [{ role: "user", content: "keep going" }], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nGENUINE RESULT" }], rawMessages: echoed, }); const serialized = JSON.stringify(decodeRoots(bytes)); expect(serialized).not.toContain("ECHOED BODY"); - expect(serialized).not.toContain("[Tool Result]"); + // The genuine replayed envelope is built from the toolResult message and must survive; only + // the copy the model pasted into its own text is removed. + expect(serialized).toContain("GENUINE RESULT"); // The model's own prose on BOTH sides of the echo survives: bounding the strip at the blank // line is what keeps the answer that follows it. expect(serialized).toContain("I wrote the import script.");