diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index e495f2a6713..7a3bbf3a6c4 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -48,6 +48,7 @@ import { 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, @@ -315,6 +316,8 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ? new CursorRoutingCommentarySniffer() : undefined; let guardHeld: AdapterEvent[] = []; + let guardHeldBytes = 0; + const guardEncoder = new TextEncoder(); // Exactly-once observation: every client-bound text delta passes through here // exactly once — held deltas only on release, ordinary deltas at emit time. const emitTextObserved = (event: AdapterEvent): void => { @@ -327,7 +330,49 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda emitTextObserved(held); } guardHeld = []; + guardHeldBytes = 0; }; + // A single frame can carry a multi-megabyte payload (the transport accepts up to the + // 16 MiB Cursor message bound), so the serialized size is projected — object overhead + // plus raw payload length — BEFORE any encoded copy exists. Escapes only inflate the + // exact figure, making the raw length a safe lower bound for the overflow decision. + const GUARD_EVENT_OVERHEAD_BYTES = 64; + const projectedGuardEventBytes = (event: AdapterEvent): number => + GUARD_EVENT_OVERHEAD_BYTES + + (event.type === "text_delta" + ? Buffer.byteLength(event.text, "utf8") + : event.type === "thinking_delta" + ? Buffer.byteLength(event.thinking, "utf8") + : 0); + const holdGuardEvent = (event: AdapterEvent) => { + if (guardHeldBytes + projectedGuardEventBytes(event) > CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES) { + // Too large to retain even unescaped: settle the sniffers, release what was held, + // and pass this event through without ever encoding it. + echoSniffer?.finish(); + routingCommentarySniffer?.finish(); + releaseGuardHeld(); + if (event.type !== "heartbeat") emittedOutput = true; + emitTextObserved(event); + return false; + } + 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; + }; + // Bound each feed before a sniffer copies or encodes it. Their normal 40 B / 512 B + // hold thresholds are checked after classification, so one large frame previously + // let a late match inspect an arbitrary tail. Only these leading UTF-16 prefixes + // now participate in corrective retry; later text remains ordinary output. + const ECHO_SNIFF_FEED_MAX_CHARS = 512; + const ROUTING_SNIFF_FEED_MAX_CHARS = 2048; + const boundedSniffText = (text: string, maxChars: number): string => + text.length > maxChars ? text.slice(0, maxChars) : text; const guardsSettled = () => (!echoSniffer || echoSniffer.settled) && (!routingCommentarySniffer || routingCommentarySniffer.settled); @@ -368,27 +413,30 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } if (!guardsSettled()) { if (event.type === "text_delta") { - guardHeld.push(event); + // Classify the delta before the aggregate-cap check: an oversized first + // delta must still pass the armed sniffers (echo/hallucination detection is + // prefix-based), so the cap cannot disarm them before they see the text. if (echoSniffer && !echoSniffer.settled) { - const decision = echoSniffer.feed(event.text); + const decision = echoSniffer.feed(boundedSniffText(event.text, ECHO_SNIFF_FEED_MAX_CHARS)); if (decision.kind === "echo") { guardHeld = []; throw new CursorToolResultEchoError(decision.marker); } } if (routingCommentarySniffer && !routingCommentarySniffer.settled) { - const decision = routingCommentarySniffer.feed(event.text); + const decision = routingCommentarySniffer.feed(boundedSniffText(event.text, ROUTING_SNIFF_FEED_MAX_CHARS)); if (decision.kind === "hallucination") { guardHeld = []; throw new CursorRoutingCommentaryError(); } } + if (!holdGuardEvent(event)) continue; if (guardsSettled()) releaseGuardHeld(); continue; } 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 4691731fa01..07c992262d8 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -76,7 +76,7 @@ export const MAX_MIDSTREAM_SCAN_LENGTH = 512 * 1024; const MAX_MIDSTREAM_FINDINGS = 8; 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 { @@ -130,16 +130,19 @@ export class CursorMidstreamEchoObserver { private totalLength = 0; private disarmed = false; private lineDisarmed = false; - private corruptionWatch: { finding: MidstreamEchoFinding; remaining: number; window: string } | undefined; + private readonly corruptionWatches: Array<{ + finding: MidstreamEchoFinding; + remaining: number; + window: string; + }> = []; private readonly recorded: MidstreamEchoFinding[] = []; feed(textDelta: string): void { - if (this.disarmed && !this.corruptionWatch) return; + if (this.disarmed && this.corruptionWatches.length === 0) return; let index = 0; while (index < textDelta.length) { const newline = textDelta.indexOf("\n", index); const segment = newline === -1 ? textDelta.slice(index) : textDelta.slice(index, newline); - if (this.corruptionWatch) this.watchCorruption(segment + (newline === -1 ? "" : "\n")); if (!this.disarmed && !this.lineDisarmed && segment.length > 0) { this.lineBuffer += segment; if (this.lineBuffer.length > MAX_MIDSTREAM_LINE_INDENT + 32) { @@ -149,6 +152,11 @@ export class CursorMidstreamEchoObserver { } this.checkLine(); } + // Recognize the next marker before charging its line to a corruption window. + // A call-id on the marker's own line belongs to the new finding as well. + if (this.corruptionWatches.length > 0) { + this.watchCorruption(segment + (newline === -1 ? "" : "\n")); + } if (newline === -1) break; this.lineBuffer = ""; this.lineDisarmed = false; @@ -160,9 +168,7 @@ export class CursorMidstreamEchoObserver { } findings(): readonly MidstreamEchoFinding[] { - if (this.corruptionWatch) { - this.settleCorruption(); - } + while (this.corruptionWatches.length > 0) this.settleCorruption(0); return this.recorded; } @@ -187,12 +193,24 @@ export class CursorMidstreamEchoObserver { this.lineDisarmed = true; return; } - const finding: MidstreamEchoFinding = { - marker, - offset: this.lineStartOffset, - callIdCorrupt: false, - }; - this.corruptionWatch = { finding, remaining: MIDSTREAM_CORRUPTION_WINDOW, window: "" }; + // A new marker ends the previous marker's corruption window: the text + // between two markers belongs to the earlier finding only. Without this, + // every open watch consumed the same following text, so one corrupt + // call-id after a second marker also marked the first, clean finding + // corrupt (clean-then-corrupt cross-contamination). + while (this.corruptionWatches.length > 0) this.settleCorruption(0); + if (this.recorded.length + this.corruptionWatches.length < MAX_MIDSTREAM_FINDINGS) { + const finding: MidstreamEchoFinding = { + marker, + offset: this.lineStartOffset, + callIdCorrupt: false, + }; + this.corruptionWatches.push({ + finding, + remaining: MIDSTREAM_CORRUPTION_WINDOW, + window: "", + }); + } this.lineDisarmed = true; return; } @@ -203,24 +221,28 @@ export class CursorMidstreamEchoObserver { } private watchCorruption(text: string): void { - const watch = this.corruptionWatch; - if (!watch) return; - const take = Math.min(watch.remaining, text.length); - watch.window += text.slice(0, take); - watch.remaining -= take; - if (watch.remaining <= 0) this.settleCorruption(); + for (const watch of this.corruptionWatches) { + const take = Math.min(watch.remaining, text.length); + watch.window += text.slice(0, take); + watch.remaining -= take; + } + let index = 0; + while (index < this.corruptionWatches.length) { + if (this.corruptionWatches[index]!.remaining <= 0) this.settleCorruption(index); + else index += 1; + } } - private settleCorruption(): void { - const watch = this.corruptionWatch; + private settleCorruption(index: number): void { + const watch = this.corruptionWatches[index]; if (!watch) return; const window = watch.window; watch.finding.callIdCorrupt = /fc_[0-9a-f]+[ \t]+mar-/.test(window) || /call_id: \S+[ \t]+\S+_0\b/.test(window); - if (this.recorded.length < MAX_MIDSTREAM_FINDINGS) this.recorded.push(watch.finding); + this.recorded.push(watch.finding); // Window text is discarded here; only booleans/offsets survive. - this.corruptionWatch = undefined; + this.corruptionWatches.splice(index, 1); } } @@ -251,7 +273,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; @@ -316,7 +342,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/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 83e076dcced..0165c9b2438 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -311,6 +311,7 @@ function rootPromptMessages( const replayRuns = new Map(); const toolCallCounts = new Map(); @@ -337,13 +338,13 @@ function rootPromptMessages( // half, so losing it re-primes the self-reinforcing loop the breaker exists to end. { ...opts, text: marked, messageIndex: previous.entry.messageIndex ?? opts.messageIndex }, ); - entries[entries.indexOf(previous.entry)] = replacement; - replayRuns.set(role, { text: normalized, entry: replacement, length: runLength }); + entries[previous.entryIndex] = replacement; + replayRuns.set(role, { text: normalized, entry: replacement, entryIndex: previous.entryIndex, length: runLength }); return; } const entry = rootBlobCandidate(payload, role, opts); entries.push(entry); - replayRuns.set(role, { text: normalized, entry, length: 1 }); + replayRuns.set(role, { text: normalized, entry, entryIndex: entries.length - 1, length: 1 }); }; for (let i = 0; i < messages.length; i++) { @@ -949,11 +950,26 @@ function serializeToolCallArguments(args: Record): string | und /** Truncate to a byte budget without splitting a UTF-8 sequence. */ function truncateUtf8(text: string, maxBytes: number): string { - const encoded = encoder.encode(text); - if (encoded.byteLength <= maxBytes) return text; - let end = Math.max(0, maxBytes); - while (end > 0 && (encoded[end]! & 0xc0) === 0x80) end -= 1; - return decoder.decode(encoded.subarray(0, end)); + const encoded = new Uint8Array(Math.max(0, maxBytes)); + const { read, written } = encoder.encodeInto(text, encoded); + return read === text.length ? text : decoder.decode(encoded.subarray(0, written)); +} + +/** Return the UTF-8 length only when it fits the bound, without allocating an input-sized buffer. */ +function boundedUtf8ByteLength(text: string, maxBytes: number): number | undefined { + let bytes = 0; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length + && text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) { + bytes += 4; + i += 1; + } else bytes += 3; + if (bytes > maxBytes) return undefined; + } + return bytes; } /** @@ -966,10 +982,8 @@ function truncateUtf8(text: string, maxBytes: number): string { * exists to prevent. A bounded prefix still identifies the call (tool name plus the head of its * arguments) while leaving the output room to survive. */ -function toolCallArgumentsText(args: Record): string { - const serialized = serializeToolCallArguments(args); - if (serialized === undefined) return "[unserializable arguments]"; - if (encoder.encode(serialized).byteLength <= CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) return serialized; +function serializedToolCallArgumentsText(serialized: string): string { + if (boundedUtf8ByteLength(serialized, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) !== undefined) return serialized; // The budget is the size of the RENDERED line, so the marker has to come out of it rather than be // added on top: otherwise every truncated invocation exceeds the declared limit by the marker. const marker = "…[arguments truncated]"; @@ -978,6 +992,11 @@ function toolCallArgumentsText(args: Record): string { return `${truncateUtf8(serialized, keep)}${marker}`; } +function toolCallArgumentsText(args: Record): string { + const serialized = serializeToolCallArguments(args); + return serialized === undefined ? "[unserializable arguments]" : serializedToolCallArgumentsText(serialized); +} + /** * The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the * result envelope. @@ -1052,8 +1071,15 @@ function restoreClippedInvocationArguments( if (!call) continue; const full = serializeToolCallArguments(call.arguments); if (full === undefined) continue; - const clipped = toolCallArgumentsText(call.arguments); + const clipped = serializedToolCallArgumentsText(full); if (clipped === full) continue; + // The replacement must add at least the raw UTF-8 argument-byte delta. Reject an impossible + // restoration with a bounded scan before building the widened string, JSON, and byte array. + const clippedBytes = encoder.encode(clipped).byteLength; + // boundedUtf8ByteLength already gives up past clippedBytes + spare, so a returned number + // always fits; a second size comparison here can never fire. + const fullBytes = boundedUtf8ByteLength(full, clippedBytes + spare); + if (fullBytes === undefined) continue; const name = namespacedToolName(call.namespace, call.name); // Anchored on the preceding newline. `toolResultToText` always emits the invocation after the // `[tool_result]`, `call_id:` and `name:` lines, so the real line is never first — and diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 7252130843a..30ef6cfb9f8 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -116,6 +116,8 @@ a small replay would otherwise clip a completed call's arguments with nearly the unused. After every pruning and truncation decision is final, a second pass re-widens clipped invocation lines out of the leftover aggregate bytes only: newest tool result first, skipping a root whose own output was already elided, and never dropping, shrinking or reordering a retained root. +Before materializing a widened root, the pass uses a bounded UTF-8 scan to reject arguments whose +raw byte growth alone cannot fit the spare budget, and reuses its single argument serialization. The elision skip is load bearing, reached through initiator recovery rather than through truncation alone: a truncated root undershoots its own budget by far less than a restoration costs, but after the equal-share pass elides a trailing run, recovery drops an elided sibling to fit the user turn and @@ -192,6 +194,29 @@ Translated Chat request construction uses the [inline-image budget](../transport ## Mid-stream envelope echo +External root replay replaces duplicate runs at their recorded entry index, preserving the +original message position without rescanning the accumulated roots. Construction still visits +the complete supplied history before the existing count and byte admission rules; it does not +cut a raw-message suffix that could lose the initiating user instruction or checkpoint offsets. + +Held quarantine output is bounded by the aggregate `CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES` (8 KiB) +budget in `src/adapters/cursor.ts`. Text deltas are fed to the armed echo and +routing-commentary sniffers BEFORE the cap check, so a single oversized first delta cannot +disarm the guards without being classified; each sniffer reads only the bounded leading window +its decision needs. Retained bytes are projected from payload length before any serialized +copy exists, so a multi-megabyte frame cannot force a same-size encoded allocation. An event +that cannot fit the remaining budget settles both sniffers, releases the held events, and is +emitted directly. +Each adapter feed is limited to 512 UTF-16 code units for envelope detection and 2,048 for +routing-commentary detection. Matches beyond that frame prefix intentionally do not trigger a +corrective retry; the complete text still reaches the client and the diagnostic midstream +observer. These feed limits bound temporary copies and do not promise frame-independent parsing. + +Adjacent midstream markers retain separate findings, capped at eight. A new marker closes the +previous corruption window before consuming its line, including a call-id on the marker's own +line. Only marker identities, offsets and corruption booleans survive; held reasoning is released +in order before terminal errors, preserving upstream error visibility. + 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 diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index daeeb77176a..be422c9845d 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -96,8 +96,12 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga observer.feed("Leading text about progress.\n"); observer.feed(RUN03_SPECIMEN); const findings = observer.findings(); - expect(findings).toHaveLength(1); - expect(findings[0]!.callIdCorrupt).toBe(true); + expect(findings).toHaveLength(2); + expect(findings[0]!.marker).toBe("[Tool Result]"); + // The corrupt call-id sits after the second (duplicated) marker, so it is + // attributed to that marker's window — the first marker's span is clean. + expect(findings[0]!.callIdCorrupt).toBe(false); + expect(findings[1]!.callIdCorrupt).toBe(true); }); test("clean call-id lines do not flag corruption", () => { @@ -105,8 +109,8 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga observer.feed("Leading text.\n"); observer.feed("[Tool Result]\n[tool_result]\ncall_id: call-1\nfc_63367283-2aec-9a25_1\noutput:\nok\n"); const findings = observer.findings(); - expect(findings).toHaveLength(1); - expect(findings[0]!.callIdCorrupt).toBe(false); + expect(findings).toHaveLength(2); + expect(findings.every(finding => !finding.callIdCorrupt)).toBe(true); }); test("a marker fragmented across delta boundaries still fires", () => { @@ -119,6 +123,38 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(findings[0]!.callIdCorrupt).toBe(true); }); + test("closely spaced markers retain independent corruption findings", () => { + const observer = new CursorMidstreamEchoObserver(); + observer.feed("lead\n[Tool Result]\nfc_63367283 mar-broken_0\n[Tool Error]\nclean\n"); + expect(observer.findings()).toEqual([ + { marker: "[Tool Result]", offset: 5, callIdCorrupt: true }, + { marker: "[Tool Error]", offset: 44, callIdCorrupt: false }, + ]); + }); + + test("a clean marker is not contaminated by corruption after the next marker", () => { + const observer = new CursorMidstreamEchoObserver(); + observer.feed("lead\n[Tool Result]\nclean\n[Tool Error]\nfc_123 mar-broken_0\n"); + const findings = observer.findings(); + expect(findings).toHaveLength(2); + expect(findings[0]!.marker).toBe("[Tool Result]"); + expect(findings[0]!.callIdCorrupt).toBe(false); + expect(findings[1]!.marker).toBe("[Tool Error]"); + expect(findings[1]!.callIdCorrupt).toBe(true); + }); + + test.each([false, true])("same-line corruption belongs to the new marker (split=%s)", split => { + const observer = new CursorMidstreamEchoObserver(); + observer.feed("lead\n[Tool Result]\nclean\n"); + if (split) { + observer.feed("[Tool Er"); + observer.feed("ror] fc_123 mar-broken_0\n"); + } else { + observer.feed("[Tool Error] fc_123 mar-broken_0\n"); + } + expect(observer.findings().map(finding => finding.callIdCorrupt)).toEqual([false, true]); + }); + test("a mid-line marker mention does not fire", () => { const observer = new CursorMidstreamEchoObserver(); observer.feed("first line\nThe string [Tool Result] appeared in the transcript I reviewed.\n"); @@ -313,6 +349,177 @@ 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("an oversized first text delta is still classified by the echo sniffer", async () => { + // One text delta larger than the aggregate hold cap whose leading bytes are the + // echoed envelope marker. + let attempt = 0; + const runRequests: CursorRunRequest[] = []; + const oversizedFactory = () => ({ + async *run(request: CursorRunRequest) { + runRequests.push(request); + attempt += 1; + if (attempt === 1) { + yield { type: "text", text: ECHO_TEXT + "x".repeat(32 * 1024) } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + return; + } + yield { type: "text", text: "STATE A17" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: oversizedFactory as never }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(toolResultBody("cursor/kimi-k3"), { headers: new Headers() }, event => events.push(event)); + expect(attempt).toBe(2); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("STATE A17"); + }); + + test("an oversized first text delta is still classified by the routing sniffer", async () => { + let attempt = 0; + const factory = () => ({ + async *run() { + attempt += 1; + if (attempt === 1) { + // Routing claim padded past the 8 KiB aggregate cap in a single delta. + yield { + type: "text", + text: "네이티브 셸은 차단됐으니 exec_command 경로로 읽겠습니다. " + "x".repeat(32 * 1024), + } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + return; + } + yield { type: "text", text: "READ_OK" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const body = { + modelId: "cursor/kimi-k3-1m", + context: { + messages: [{ role: "user", content: "Read the file and report its first line.", timestamp: 1 }], + tools: [{ + name: "exec", + description: "Run JavaScript code to orchestrate nested tool calls.", + parameters: {}, + freeform: true, + }], + }, + stream: false, + options: {}, + _cursorConversationId: "cursor_routing_oversized", + _cursorIdentityScope: "acct-routing-commentary", + } as OcxParsedRequest; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(attempt).toBe(2); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("READ_OK"); + }); + + test("a single reasoning frame larger than the cap flushes without unbounded retention", async () => { + let attempt = 0; + const bigThinking = "y".repeat(64 * 1024); + const factory = () => ({ + async *run() { + attempt += 1; + yield { type: "thinking", thinking: bigThinking } satisfies CursorServerMessage; + yield { type: "text", text: "post-thought answer" } 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); + const thinking = events.filter(e => e.type === "thinking_delta").map(e => (e as { thinking: string }).thinking).join(""); + expect(thinking).toBe(bigThinking); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("post-thought answer"); + }); + + test.each([ + " ".repeat(513) + ECHO_TEXT + "x".repeat(32 * 1024), + "Ordinary prose. ".repeat(150) + "Shell is blocked; switching to exec_command. " + "x".repeat(32 * 1024), + ])("matches beyond bounded feed prefixes remain ordinary output", async text => { + let attempts = 0; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: (() => ({ + async *run() { + attempts++; + yield { type: "text", text } satisfies CursorServerMessage; + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + })) as never, + }); + const body = toolResultBody("cursor/kimi-k3"); + body.context.tools = [{ name: "exec", description: "Execute tools", parameters: {}, freeform: true }]; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(attempts).toBe(1); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(text); + expect(events.some(event => event.type === "error")).toBe(false); + }); + + test.each([1, 100])("quarantine keeps reasoning ordered before an upstream error (%s frames)", async count => { + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: (() => ({ + async *run() { + for (let i = 0; i < count; i++) { + yield { type: "thinking", thinking: `${i}:` + "x".repeat(128) } satisfies CursorServerMessage; + } + yield { type: "error", message: "upstream fixture failure" } satisfies CursorServerMessage; + }, + writeClient() {}, + })) as never, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(toolResultBody("cursor/kimi-k3"), { headers: new Headers() }, event => events.push(event)); + const output = events.filter(event => event.type === "thinking_delta" || event.type === "error"); + expect(output.filter(event => event.type === "thinking_delta").map(event => event.thinking)) + .toEqual(Array.from({ length: count }, (_, i) => `${i}:` + "x".repeat(128))); + expect(output.at(-1)).toMatchObject({ type: "error", message: "upstream fixture failure" }); + }); + test("plain user turns (no trailing toolResult) never arm the sniffer", async () => { let attempt = 0; const factory = () => ({ @@ -511,4 +718,3 @@ describe("Cursor midstream envelope-echo remint", () => { clearCursorIncompleteToolRemintForTests(); }); }); - diff --git a/tests/providers/cursor/cursor-repetition-breaker.test.ts b/tests/providers/cursor/cursor-repetition-breaker.test.ts index 48b9ca0ac2a..bba58a800e3 100644 --- a/tests/providers/cursor/cursor-repetition-breaker.test.ts +++ b/tests/providers/cursor/cursor-repetition-breaker.test.ts @@ -76,6 +76,33 @@ function encode(messages: OcxMessage[], modelId = "grok-4.6-high") { } describe("cursor external-replay repetition breaker (devlog 260826 gap-9)", () => { + test("a long collapsed turn keeps its initiating instruction and full repetition count", () => { + const texts = rootTexts(encode(repeatedHistory(4100))); + expect(texts.some(text => text.includes("원격 ocx를 최신 버전으로 업데이트해봐"))).toBe(true); + expect(texts.filter(text => text.startsWith(REPEAT))).toHaveLength(1); + expect(texts.find(text => text.startsWith(REPEAT))).toContain("4100 times in a row"); + }); + + test("a long trailing result run keeps the instruction and invocation preceding the run", () => { + const messages: OcxMessage[] = [ + { role: "user", content: "Read the result and explain it", timestamp: 1 }, + { role: "assistant", content: [ + { type: "toolCall", id: "long_call", name: "read_file", arguments: { path: "fixture.txt" } }, + ], timestamp: 2 }, + ...Array.from({ length: 4100 }, (_, i) => ({ + role: "toolResult" as const, toolCallId: "long_call", toolName: "read_file", + content: "long-run-result", isError: false, timestamp: i + 3, + })), + ]; + const texts = rootTexts(encode(messages)); + expect(texts).toContain("Read the result and explain it"); + const result = texts.find(text => text.startsWith("[Tool Result]")); + expect(result).toContain("read_file"); + expect(result).toContain('"path":"fixture.txt"'); + expect(result).toContain("long-run-result"); + expect(result).toContain("4100 times in a row"); + }); + test("consecutive identical assistant entries collapse into one marked entry", () => { const texts = rootTexts(encode(repeatedHistory(5))); const repeats = texts.filter(text => text.startsWith(REPEAT)); diff --git a/tests/providers/cursor/cursor-tool-result-invocation.test.ts b/tests/providers/cursor/cursor-tool-result-invocation.test.ts index 7a1097c964e..ba73cf69891 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -603,6 +603,36 @@ describe("cursor spare envelope budget restores clipped invocation arguments", ( expect(line).toContain(JSON.stringify(args)); }); + test("an impossible restoration serializes its arguments only once in the refund pass", () => { + let serializations = 0; + const args = { + toJSON() { + serializations++; + return { contents: "A".repeat(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + 1) }; + }, + }; + // The refund pass must not allocate a full-size byte buffer while deciding whether the + // restoration fits. encode() calls with a string longer than the envelope mean someone + // reintroduced the unbounded fullBytes measurement this change removed. + let oversizedEncodes = 0; + const originalEncode = TextEncoder.prototype.encode; + TextEncoder.prototype.encode = function (input?: string) { + if (typeof input === "string" && input.length > CURSOR_EXTERNAL_ROOT_BYTE_LIMIT) oversizedEncodes++; + return originalEncode.call(this, input); + }; + let line: string | undefined; + try { + line = invokedLine(resultRoot(encode(writeFileHistory(args), "grok-4.6-high"))); + } finally { + TextEncoder.prototype.encode = originalEncode; + } + expect(line).toEndWith("…[arguments truncated]"); + // Indexing and initial rendering account for three calls; the refund pass adds exactly one and + // must reuse that serialization instead of calling the rendering helper for a fifth copy. + expect(serializations).toBe(4); + expect(oversizedEncodes).toBe(0); + }); + // The refund pass must be a no-op below the cap: a line that was never clipped has nothing to // restore, and rewriting it would only risk drift from the admission-time rendering. test("an under-cap argument is unchanged", () => {