From c0f398198f45f992e8ad7c3378a275da77c371b1 Mon Sep 17 00:00:00 2001 From: MerryEcho Date: Sat, 19 Sep 2026 11:52:01 +0800 Subject: [PATCH 1/2] fix(cursor,xai): stop grok-4.6 mid-turn tool-result echoes from sticking Cursor remint after a mid-stream [Tool Result] paste did not stick when Codex Desktop restored the pre-remint conversation id from providerState and omitted x-codex-parent-thread-id. The echo also reached the client as a successful final answer on both cursor/grok-4.6 and xai/grok-4.6. Strip the envelope from client-visible Cursor deltas and xAI Responses SSE/JSON, remember a conversation-id rewrite map, and remint even when the client omitted a thread owner. --- scripts/test-layout/layout.json | 1 + src/adapters/cursor.ts | 41 +++++++-- src/adapters/cursor/request-builder.ts | 12 ++- src/adapters/cursor/thread-continuity.ts | 62 +++++++++++++ src/server/grok-upstream-envelope-echo.ts | 91 ++++++++++++++++++ src/server/responses/passthrough-delivery.ts | 12 +++ structure/providers/cursor.md | 21 +++-- structure/providers/xai-grok.md | 7 ++ structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../cursor/cursor-envelope-echo-retry.test.ts | 67 +++++++++++++- ...hrough-grok-upstream-envelope-echo.test.ts | 92 +++++++++++++++++++ 12 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 src/server/grok-upstream-envelope-echo.ts create mode 100644 tests/responses/passthrough-grok-upstream-envelope-echo.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 906114492d..0d3a528781 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1079,6 +1079,7 @@ "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", "passthrough-abort.test.ts": "responses", + "passthrough-grok-upstream-envelope-echo.test.ts": "responses", "passthrough-headers.test.ts": "responses", "passthrough-override.test.ts": "responses", "phase100-native-parity.test.ts": "e2e-style", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 65f4393877..1a30edf703 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -41,6 +41,7 @@ import { recordCursorIncompleteToolRemint, recordCursorEnvelopeEchoRemint, recordCursorOverflowRemint, + rememberCursorConversationRewrite, rememberCursorThreadConversation, shouldSkipCursorOverflowRemint, shouldSurfaceCursorOverflowFirst, @@ -55,6 +56,7 @@ import { CursorRoutingCommentaryError, CursorRoutingCommentarySniffer, CursorToolResultEchoError, + stripAssistantEchoedToolEnvelope, } from "./cursor/envelope-echo"; import { createDisabledCursorTransport, @@ -306,8 +308,22 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda let guardHeld: AdapterEvent[] = []; // Exactly-once observation: every client-bound text delta passes through here // exactly once — held deltas only on release, ordinary deltas at emit time. + let suppressMidstreamEchoTail = false; const emitTextObserved = (event: AdapterEvent): void => { - if (event.type === "text_delta") midstreamObserver?.feed(event.text); + if (event.type === "text_delta") { + if (suppressMidstreamEchoTail) return; + if (midstreamObserver) { + midstreamObserver.feed(event.text); + const kept = stripAssistantEchoedToolEnvelope(event.text); + const found = midstreamObserver.findings().length > 0 || kept !== event.text; + if (found) { + suppressMidstreamEchoTail = true; + sawMidstreamEnvelopeEcho = true; + if (kept && kept !== event.text) emit({ ...event, text: kept }); + return; + } + } + } emit(event); }; const releaseGuardHeld = () => { @@ -448,6 +464,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda // the next turn does not recompute the stale deterministic thread hash. Isolated helper / // compaction turns must not park their throwaway id under the parent or Desktop owner. const threadOwner = cursorClientThreadOwner(_parsed); + rememberCursorConversationRewrite(failedConversationId, next.conversationId); if (threadOwner && _parsed._cursorIsolateConversation !== true) { rememberCursorThreadConversation( threadOwner, @@ -570,11 +587,10 @@ 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. + // A mid-stream envelope echo is stripped from client-visible deltas, but any leading + // prose already escaped, so this send is not retried. grok-4.6 writes a real sentence + // before pasting the envelope; rotating the next turn's id still matters because Codex + // Desktop may restore the pre-remint conversation from providerState. // // 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 @@ -584,12 +600,19 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda _parsed._cursorIsolateConversation !== true && request.contextUsageStoreCheckpoints !== false ? cursorEnvelopeEchoRemintScopeKey( - cursorClientThreadOwner(_parsed), + cursorClientThreadOwner(_parsed) + ?? _parsed._cursorConversationId + ?? request.conversationId, _parsed._cursorIdentityScope, ) : null; - if (sawMidstreamEnvelopeEcho && !sawIncompleteToolCall && envelopeEchoRemintScopeKey) { - if (recordCursorEnvelopeEchoRemint(envelopeEchoRemintScopeKey)) { + if (sawMidstreamEnvelopeEcho && !sawIncompleteToolCall + && _parsed._cursorIsolateConversation !== true + && request.contextUsageStoreCheckpoints !== false) { + const allowed = envelopeEchoRemintScopeKey + ? recordCursorEnvelopeEchoRemint(envelopeEchoRemintScopeKey) + : true; + if (allowed) { if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); debugProviderDiagnostic("cursor", "midstream-envelope-echo-remint", { wireModel: request.modelId, diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 3eb922b968..2f254b2b89 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -26,7 +26,7 @@ import { isCursorExecutionPathTool, isCursorWaitTool, } from "./tool-definitions"; -import { lookupCursorThreadConversation } from "./thread-continuity"; +import { lookupCursorThreadConversation, resolveCursorConversationRewrite } from "./thread-continuity"; import { getCursorCheckpoint, getCursorCheckpointForPrefix, @@ -363,11 +363,15 @@ export function resolveCursorConversationId( // 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; + if (recovered) return resolveCursorConversationRewrite(recovered); + } + if (parsed._cursorConversationId) { + return resolveCursorConversationRewrite(parsed._cursorConversationId); } - if (parsed._cursorConversationId) return parsed._cursorConversationId; if (threadId) { - return cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope); + return resolveCursorConversationRewrite( + cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope), + ); } return generatedCursorConversationId(); } diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index 72778ce4c3..71cf94eedb 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -62,8 +62,70 @@ export function lookupCursorThreadConversation( return entry.conversationId; } +const conversationRewrites = new Map(); + +function pruneConversationRewrites(at: number): void { + for (const [key, entry] of conversationRewrites) { + if (at - entry.updatedAt > OVERRIDE_TTL_MS) conversationRewrites.delete(key); + else break; // Map iterates insertion order; refreshed entries are moved to the end + } + while (conversationRewrites.size > OVERRIDE_MAX_ENTRIES) { + const oldest = conversationRewrites.keys().next().value; + if (oldest === undefined) break; + conversationRewrites.delete(oldest); + } +} + +function rememberRewriteEntry(from: string, to: string, at: number): void { + conversationRewrites.delete(from); + conversationRewrites.set(from, { to, updatedAt: at }); +} + +/** + * Remember that a poisoned Cursor conversation id was rotated away. + * + * Codex Desktop often restores the pre-remint id from providerState on the next + * Responses request and may omit x-codex-parent-thread-id. Thread-owner + * lookup then misses, and the next turn resumes the same Cursor conversation. + * Rewriting the old wire id itself survives that restore path. + */ +export function rememberCursorConversationRewrite( + fromConversationId: string, + toConversationId: string, +): void { + if (!fromConversationId || !toConversationId || fromConversationId === toConversationId) return; + const at = now(); + rememberRewriteEntry(fromConversationId, toConversationId, at); + const redirected: string[] = []; + for (const [from, entry] of conversationRewrites) { + if (from !== fromConversationId && entry.to === fromConversationId) redirected.push(from); + } + for (const from of redirected) rememberRewriteEntry(from, toConversationId, at); + pruneConversationRewrites(at); +} + +/** Follow a remint chain; missing or expired entries return the input id. */ +export function resolveCursorConversationRewrite(conversationId: string): string { + const at = now(); + const seen = new Set(); + let current = conversationId; + while (!seen.has(current)) { + const entry = conversationRewrites.get(current); + if (!entry) break; + if (at - entry.updatedAt > OVERRIDE_TTL_MS) { + conversationRewrites.delete(current); + break; + } + rememberRewriteEntry(current, entry.to, at); + seen.add(current); + current = entry.to; + } + return current; +} + export function clearCursorThreadContinuityForTests(): void { overrides.clear(); + conversationRewrites.clear(); } /** Max conversation-id remints after the first surfaced overflow per retained scope. */ diff --git a/src/server/grok-upstream-envelope-echo.ts b/src/server/grok-upstream-envelope-echo.ts new file mode 100644 index 0000000000..95c2e9bc92 --- /dev/null +++ b/src/server/grok-upstream-envelope-echo.ts @@ -0,0 +1,91 @@ +import { CursorMidstreamEchoObserver, stripAssistantEchoedToolEnvelope } from "../adapters/cursor/envelope-echo"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function stripOutputTextFields(value: unknown): boolean { + let stripped = false; + if (Array.isArray(value)) { + for (const item of value) stripped = stripOutputTextFields(item) || stripped; + return stripped; + } + if (!isRecord(value)) return false; + if (typeof value.text === "string") { + const next = stripAssistantEchoedToolEnvelope(value.text); + if (next !== value.text) { + value.text = next; + stripped = true; + } + } + if (typeof value.delta === "string") { + const next = stripAssistantEchoedToolEnvelope(value.delta); + if (next !== value.delta) { + value.delta = next; + stripped = true; + } + } + for (const nested of Object.values(value)) { + stripped = stripOutputTextFields(nested) || stripped; + } + return stripped; +} + +/** Strip a whole-line [Tool Result] echo from a completed Responses JSON body. */ +export function stripGrokUpstreamEnvelopeEchoFromResponsesJson(json: string): string { + try { + const parsed = JSON.parse(json) as unknown; + if (!stripOutputTextFields(parsed)) return json; + return JSON.stringify(parsed); + } catch { + return json; + } +} + +/** + * Drop grok-4.6 mid-turn [Tool Result] envelopes on the xAI Responses SSE path. + * Cursor already quarantines this on protobuf; xAI relays SSE verbatim, so the + * same paste otherwise lands in Codex as a successful final answer. + */ +export function createGrokUpstreamEnvelopeEchoBlockRewrite(): SseBlockRewrite { + const observer = new CursorMidstreamEchoObserver(); + let suppress = false; + const rewrite: SseBlockRewrite = (block) => { + const payload = sseDataPayload(block); + if (payload === null || payload === "[DONE]") return [block]; + let event: unknown; + try { + event = JSON.parse(payload); + } catch { + return [block]; + } + if (!isRecord(event) || typeof event.type !== "string") return [block]; + if (event.type === "response.output_text.delta" && typeof event.delta === "string") { + if (suppress) return []; + observer.feed(event.delta); + const kept = stripAssistantEchoedToolEnvelope(event.delta); + const found = observer.findings().length > 0 || kept !== event.delta; + if (!found) return [block]; + suppress = true; + if (!kept || kept === event.delta) return []; + event.delta = kept; + return [replaceSseDataPayload(block, JSON.stringify(event))]; + } + if (suppress && event.type === "response.output_text.delta") return []; + if (event.type === "response.output_text.done" && typeof event.text === "string") { + const kept = stripAssistantEchoedToolEnvelope(event.text); + if (kept === event.text) return [block]; + event.text = kept; + return [replaceSseDataPayload(block, JSON.stringify(event))]; + } + if (event.type === "response.completed" || event.type === "response.output_item.done") { + if (stripOutputTextFields(event)) { + return [replaceSseDataPayload(block, JSON.stringify(event))]; + } + } + return [block]; + }; + return rewrite; +} + diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index a625273356..ef71c3a74d 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -85,6 +85,11 @@ import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-sea import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; import { createGrokResponsesControlFrameBlockRewrite } from "../grok-responses-control-frame"; import { createGrokResponsesSparseTerminalBlockRewrite } from "../grok-responses-snapshot-repair"; +import { isXaiResponsesDestination } from "../../providers/xai-transport"; +import { + createGrokUpstreamEnvelopeEchoBlockRewrite, + stripGrokUpstreamEnvelopeEchoFromResponsesJson, +} from "../grok-upstream-envelope-echo"; import { createPlaintextV2AgentMessageCallRestoreRewrite, restorePlaintextV2AgentMessageCallsInJsonResult, @@ -335,6 +340,7 @@ export async function deliverPassthroughResponse( // (src/server/relay-eager.ts; policy: // devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md). // The bundled known-bad runtime remains on tee by default on both platforms. + const grokUpstreamEchoEnabled = isXaiResponsesDestination(route.provider); if (isEventStream && upstreamResponse.body) { // For streamed passthrough, a successful terminal response means non-error upstream status // before relay starts. Waiting for SSE completion would retain request state across the whole @@ -501,6 +507,9 @@ export async function deliverPassthroughResponse( grokClientCompatibilityEnabled ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget) : undefined, + grokUpstreamEchoEnabled + ? createGrokUpstreamEnvelopeEchoBlockRewrite() + : undefined, snapshotRepairEnabled ? createResponsesSnapshotBlockRewrite(nativeExchange.outboundRequestBody, translatorBudget) : undefined, @@ -769,6 +778,9 @@ export async function deliverPassthroughResponse( // the reframed-SSE branch below are built from this body, so one check covers them. This // runs BEFORE the continuation cache write below: a refused turn must not become state a // later `previous_response_id` replay can expand from. + if (grokUpstreamEchoEnabled) { + clientJson = stripGrokUpstreamEnvelopeEchoFromResponsesJson(clientJson); + } if (nativeExchange.undeclaredToolGuardActive) { const undeclared = (() => { try { diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index af22677eb5..141280a110 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -184,11 +184,13 @@ Translated Chat request construction uses the [inline-image budget](../transport ## 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. +prose first and then pastes a replayed tool-result envelope defeats it. Client-visible +`text_delta` events drop the envelope from the marker line (`stripAssistantEchoedToolEnvelope`) +and suppress later text deltas on that turn so Codex does not store the paste as the answer. +`CursorMidstreamEchoObserver` still records those findings without throwing. At turn end, eligible +non-isolated turns remint the conversation id for the NEXT turn, using the stored conversation id +as a fallback budget scope when the client omitted a thread owner. The current send is never +retried: any leading prose already escaped, 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 @@ -208,10 +210,11 @@ pasted body contains its own blank line therefore leaves a remainder in replay; 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. +`_cursorConversationId`, then rewrites a stored or hashed id that a remint has already rotated +away from. Codex Desktop often restores the pre-remint id from providerState and may omit +`x-codex-parent-thread-id`; the rewrite map is what makes that restore land on the new +conversation instead of the poisoned one. 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/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 37815ed04d..967e14d447 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -13,6 +13,13 @@ answers a replayed tool call whose output never arrived. It is deliberately not The contract for both, and the reason they do not collapse into one, is specified in [chat-compat](./chat-compat.md); it is not restated here. +Native xAI Responses SSE is otherwise relayed verbatim. A mid-turn tool-result envelope paste +is stripped in `src/server/grok-upstream-envelope-echo.ts` from `src/server/responses/passthrough-delivery.ts` +when the destination is xAI, because Cursor protobuf quarantine never runs on this path. Leading +prose is kept; the envelope and later text deltas on that turn are dropped so Codex does not store +the dump as the assistant answer. This does not remint xAI's upstream conversation and does not +treat first-hit 502s as this failure. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 48fc1fb986..8e5901e122 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -909,7 +909,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection and terminal accounting. | +| `passthrough-delivery.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection, terminal accounting, and xAI mid-turn tool-result envelope stripping. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery and the shared continuation retry counter. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ade9fea9a7..c38c7bd2c9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -904,6 +904,7 @@ "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", "passthrough-abort.test.ts": "responses", + "passthrough-grok-upstream-envelope-echo.test.ts": "responses", "passthrough-headers.test.ts": "responses", "passthrough-override.test.ts": "responses", "phase100-native-parity.test.ts": "e2e-style", diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index daeeb77176..86607cc5b8 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -18,6 +18,8 @@ import { lookupCursorThreadConversation, recordCursorEnvelopeEchoRemint, recordCursorIncompleteToolRemint, + rememberCursorConversationRewrite, + resolveCursorConversationRewrite, } from "../../../src/adapters/cursor/thread-continuity"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; @@ -474,9 +476,9 @@ describe("Cursor midstream envelope-echo remint", () => { 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]"); + const firstText = first.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(firstText).toContain("I'll write the import script now."); + expect(firstText).not.toContain("[Tool Result]"); expect(body._cursorConversationId).toBeDefined(); expect(body._cursorConversationId).not.toBe(seen[0]); expect(lookupCursorThreadConversation(threadId, "acct-midstream-echo")).toBe(body._cursorConversationId); @@ -491,6 +493,54 @@ describe("Cursor midstream envelope-echo remint", () => { clearCursorEnvelopeEchoRemintForTests(); }); + test("rewrites a stale providerState conversation id even without a client thread owner", 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: "Continuing from the previous dump.\n" } satisfies CursorServerMessage; + yield { type: "text", text: "[Tool Result]\n[tool_result]\nname: Write\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 poisoned = "cursor_poisoned_desktop_restore"; + const firstBody = { + ...toolResultBody("cursor/grok-4.6"), + _cursorIdentityScope: "acct-stale-restore", + _cursorConversationId: poisoned, + } as OcxParsedRequest; + await adapter.runTurn?.(firstBody, { headers: new Headers() }, () => {}); + expect(seen[0]).toBe(poisoned); + expect(firstBody._cursorConversationId).toBeDefined(); + expect(firstBody._cursorConversationId).not.toBe(poisoned); + + const secondBody = { + ...toolResultBody("cursor/grok-4.6"), + _cursorIdentityScope: "acct-stale-restore", + _cursorConversationId: poisoned, + } as OcxParsedRequest; + const second: AdapterEvent[] = []; + await adapter.runTurn?.(secondBody, { headers: new Headers() }, event => second.push(event)); + expect(attempts).toBe(2); + expect(seen[1]).not.toBe(poisoned); + expect(seen[1]).toBe(firstBody._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(); @@ -512,3 +562,14 @@ describe("Cursor midstream envelope-echo remint", () => { }); }); +describe("Cursor conversation rewrite map", () => { + test("follows a remint chain so a restored poisoned id lands on the latest conversation", () => { + clearCursorThreadContinuityForTests(); + rememberCursorConversationRewrite("poisoned", "fresh-1"); + rememberCursorConversationRewrite("fresh-1", "fresh-2"); + expect(resolveCursorConversationRewrite("poisoned")).toBe("fresh-2"); + expect(resolveCursorConversationRewrite("fresh-1")).toBe("fresh-2"); + expect(resolveCursorConversationRewrite("unknown")).toBe("unknown"); + clearCursorThreadContinuityForTests(); + }); +}); diff --git a/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts b/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts new file mode 100644 index 0000000000..7d6ca3eeeb --- /dev/null +++ b/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { + createGrokUpstreamEnvelopeEchoBlockRewrite, + stripGrokUpstreamEnvelopeEchoFromResponsesJson, +} from "../../src/server/grok-upstream-envelope-echo"; +import { relaySseWithBlockRewrite } from "../../src/server/sse-payload-rewrite"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +function streamFromChunks(...chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + let index = 0; + return new ReadableStream({ + pull(controller) { + const chunk = chunks[index++]; + if (chunk === undefined) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(chunk)); + }, + }); +} + +async function readAll(stream: ReadableStream): Promise { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) return text + decoder.decode(); + text += decoder.decode(value, { stream: true }); + } +} + +function frame(type: string, payload: Record): string { + return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; +} + +describe("xAI upstream grok-4.6 envelope-echo rewrite", () => { + test("keeps the leading sentence and drops a mid-turn [Tool Result] paste", async () => { + const rewrite = createGrokUpstreamEnvelopeEchoBlockRewrite(); + const upstream = [ + frame("response.output_text.delta", { + type: "response.output_text.delta", + delta: "I'll inspect the repositories next.\n", + }), + frame("response.output_text.delta", { + type: "response.output_text.delta", + delta: "[Tool Result]\n[tool_result]\ncall_id: 3\noutput: dumped\n", + }), + frame("response.output_text.delta", { + type: "response.output_text.delta", + delta: "this tail must not leak\n", + }), + frame("response.completed", { + type: "response.completed", + response: { + output: [{ + type: "message", + role: "assistant", + content: [{ + type: "output_text", + text: "I'll inspect the repositories next.\n[Tool Result]\n[tool_result]\ncall_id: 3\noutput: dumped\n", + }], + }], + }, + }), + ]; + const out = await readAll(relaySseWithBlockRewrite( + streamFromChunks(...upstream), + rewrite, + createTestTranslatorBudget(), + )); + expect(out).toContain("I'll inspect the repositories next."); + expect(out).not.toContain("[Tool Result]"); + expect(out).not.toContain("[tool_result]"); + expect(out).not.toContain("this tail must not leak"); + }); + + test("strips the same envelope from a completed JSON body", () => { + const json = JSON.stringify({ + output: [{ + type: "message", + content: [{ type: "output_text", text: "Done.\n[Tool Result]\nsecret\n" }], + }], + }); + const stripped = stripGrokUpstreamEnvelopeEchoFromResponsesJson(json); + expect(stripped).toContain("Done."); + expect(stripped).not.toContain("[Tool Result]"); + }); +}); + From 50dd1a3564d15c82cad9379370589929967c55ce Mon Sep 17 00:00:00 2001 From: MerryEcho Date: Sat, 19 Sep 2026 14:03:00 +0800 Subject: [PATCH 2/2] fix(cursor): strip grok Tool-call and truncated Tool-result echoes Live cursor/grok-4.6 threads were pasting Tool call Glob envelopes and unclosed Tool Result lines into commentary and final answers because the quarantine only matched exact whole-line Tool Result markers. --- src/adapters/cursor/envelope-echo.ts | 9 ++++++--- src/server/grok-upstream-envelope-echo.ts | 1 - .../cursor/cursor-envelope-echo-retry.test.ts | 12 ++++++++++++ .../passthrough-grok-upstream-envelope-echo.test.ts | 1 - 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index 4691731fa0..4fc630e947 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -12,10 +12,11 @@ * cursor.ts to retry before any invalid text reaches the client. */ -const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const; +const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]", "[Tool Result", "[Tool Error", "[tool_result", "[Tool call:"] as const; function isEchoMarkerLine(line: string): boolean { - return (ECHO_MARKERS as readonly string[]).includes(line.replace(/^[ \t]+/, "")); + const probe = line.replace(/^[ \t]*/, ""); + return ECHO_MARKERS.some(marker => probe.startsWith(marker)); } /** @@ -39,7 +40,9 @@ function isEchoMarkerLine(line: string): boolean { * 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. + * Only line-leading envelope prefixes count, so prose such as "the string [Tool Result] appeared" + * survives. Prefixes are used because live grok-4.6 also pastes \`[Tool call: Glob\` and truncated + * \`[Tool Result\` lines that never equal the old exact markers. */ export function stripAssistantEchoedToolEnvelope(text: string): string { if (!text || !ECHO_MARKERS.some(marker => text.includes(marker))) return text; diff --git a/src/server/grok-upstream-envelope-echo.ts b/src/server/grok-upstream-envelope-echo.ts index 95c2e9bc92..146a3a8209 100644 --- a/src/server/grok-upstream-envelope-echo.ts +++ b/src/server/grok-upstream-envelope-echo.ts @@ -88,4 +88,3 @@ export function createGrokUpstreamEnvelopeEchoBlockRewrite(): SseBlockRewrite { }; return rewrite; } - diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index 86607cc5b8..5864fac3f9 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -441,6 +441,18 @@ describe("stripAssistantEchoedToolEnvelope", () => { test("drops a prefix-only envelope to empty text", () => { expect(stripAssistantEchoedToolEnvelope("[Tool Result]\n[tool_result]\ncall_id: 1\n")).toBe(""); }); + + test("drops a trailing [Tool call: echo after commentary", () => { + expect(stripAssistantEchoedToolEnvelope( + "I will locate the existing registration scripts and back up the tasks.\n[Tool call: Glob", + )).toBe("I will locate the existing registration scripts and back up the tasks."); + }); + + test("drops a truncated [Tool Result line without a closing bracket", () => { + expect(stripAssistantEchoedToolEnvelope( + "The previous read exceeded the parameter limit.\n[Tool Result", + )).toBe("The previous read exceeded the parameter limit."); + }); }); describe("Cursor midstream envelope-echo remint", () => { diff --git a/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts b/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts index 7d6ca3eeeb..2073073be1 100644 --- a/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts +++ b/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts @@ -89,4 +89,3 @@ describe("xAI upstream grok-4.6 envelope-echo rewrite", () => { expect(stripped).not.toContain("[Tool Result]"); }); }); -