diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 6761a3d194..a02f596a0a 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -31,6 +31,7 @@ import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { cursorRequestHasShellAlias, cursorRequestUsesCodeMode } from "./cursor/tool-definitions"; import { + CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES, CURSOR_ECHO_RETRY_CONTINUATION_TEXT, CURSOR_ROUTING_COMMENTARY_RETRY_TEXT, CursorEnvelopeEchoSniffer, @@ -242,12 +243,26 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ? new CursorRoutingCommentarySniffer() : undefined; let guardHeld: AdapterEvent[] = []; + let guardHeldBytes = 0; + const guardEncoder = new TextEncoder(); const releaseGuardHeld = () => { for (const held of guardHeld) { if (held.type !== "heartbeat") emittedOutput = true; emit(held); } guardHeld = []; + guardHeldBytes = 0; + }; + const holdGuardEvent = (event: AdapterEvent) => { + guardHeld.push(event); + // Count the complete retained representation, including per-event overhead, so an + // upstream cannot evade the cap with empty or non-text reasoning frames. + guardHeldBytes += guardEncoder.encode(JSON.stringify(event)).byteLength; + if (guardHeldBytes <= CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES) return true; + echoSniffer?.finish(); + routingCommentarySniffer?.finish(); + releaseGuardHeld(); + return false; }; const guardsSettled = () => (!echoSniffer || echoSniffer.settled) @@ -286,7 +301,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda for (const event of events) { if (!guardsSettled()) { if (event.type === "text_delta") { - guardHeld.push(event); + if (!holdGuardEvent(event)) continue; if (echoSniffer && !echoSniffer.settled) { const decision = echoSniffer.feed(event.text); if (decision.kind === "echo") { @@ -306,7 +321,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } else if (event.type === "thinking_delta" || event.type === "heartbeat") { // Reasoning before first text stays ordered; liveness still passes through. if (event.type === "thinking_delta") { - guardHeld.push(event); + holdGuardEvent(event); continue; } } else { diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index ac7ec429c3..dd78d1a9e0 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -16,7 +16,7 @@ const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const const MAX_SNIFF_BYTES = 40; const MAX_ROUTING_COMMENTARY_BYTES = 512; /** Aggregate quarantine cap: past this, flush and disarm. */ -const MAX_HOLD_BYTES = 8 * 1024; +export const CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES = 8 * 1024; const encoder = new TextEncoder(); export class CursorToolResultEchoError extends Error { @@ -70,7 +70,11 @@ export class CursorEnvelopeEchoSniffer { const stillPrefix = ECHO_MARKERS.some(marker => probe.length < marker.length && marker.startsWith(probe), ); - if (stillPrefix && this.byteCount <= MAX_SNIFF_BYTES && this.buffered.length < MAX_HOLD_BYTES) { + if ( + stillPrefix + && this.byteCount <= MAX_SNIFF_BYTES + && this.buffered.length < CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES + ) { return { kind: "hold" }; } this.done = true; @@ -129,7 +133,7 @@ export class CursorRoutingCommentarySniffer { && lineBreakCount < 2; if ( this.byteCount < MAX_ROUTING_COMMENTARY_BYTES - && this.buffered.length < MAX_HOLD_BYTES + && this.buffered.length < CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES && (lineBreakCount === 0 || pendingFailureClaim) && (hasRoutingHint || this.byteCount < 64) ) { diff --git a/tests/cursor-envelope-echo-retry.test.ts b/tests/cursor-envelope-echo-retry.test.ts index 0cba08740b..12580b8331 100644 --- a/tests/cursor-envelope-echo-retry.test.ts +++ b/tests/cursor-envelope-echo-retry.test.ts @@ -154,6 +154,37 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(text).toBe("[note] leading bracket but not an envelope"); }); + test("reasoning-only quarantine is capped and disarms before unbounded retention", async () => { + let attempt = 0; + const factory = () => ({ + async *run() { + attempt += 1; + for (let i = 0; i < 100; i += 1) { + yield { type: "thinking", thinking: "x".repeat(128) } satisfies CursorServerMessage; + } + // Once the aggregate hold cap flushes, later marker-like text is ordinary output rather + // than evidence for a retry whose preceding reasoning has already reached the client. + yield { type: "text", text: ECHO_TEXT } 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 events: AdapterEvent[] = []; + await adapter.runTurn?.( + toolResultBody("cursor/kimi-k3"), + { headers: new Headers() }, + event => events.push(event), + ); + + expect(attempt).toBe(1); + expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(100); + expect(events.filter(event => event.type === "text_delta")).not.toHaveLength(0); + }); + test("plain user turns (no trailing toolResult) never arm the sniffer", async () => { let attempt = 0; const factory = () => ({