Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Comment on lines +261 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Inspect cap-crossing text before disarming the quarantine

When retained reasoning leaves guardHeldBytes just below 8 KiB and the next first text_delta both crosses the cap and begins with [Tool Result] or matching routing-failure commentary, this branch settles both sniffers and emits the held events before the caller feeds that text to them. The invalid marker therefore reaches the client and sets emittedOutput, preventing the corrective retry even though nothing had escaped before this delta; evaluate a cap-crossing text event with the active sniffers before deciding to flush and disarm.

AGENTS.md reference: src/AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

return false;
};
const guardsSettled = () =>
(!echoSniffer || echoSniffer.settled)
Expand Down Expand Up @@ -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") {
Expand All @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions src/adapters/cursor/envelope-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
) {
Expand Down
31 changes: 31 additions & 0 deletions tests/cursor-envelope-echo-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => ({
Expand Down
Loading