From 49db22e74845082518c4355c036eb0c4858a6c34 Mon Sep 17 00:00:00 2001 From: MerryEcho Date: Thu, 17 Sep 2026 15:46:58 +0800 Subject: [PATCH 1/2] fix(cursor): remint conversation after incomplete tool-call streams (#4874) Cursor fail-closes a truncated client-tool stream but kept the same conversation id, so the next turn resumed a session left waiting for mcpResult. Remint after the streamed error, persist the thread override, and synthesize a missing tool_result on native Composer replay. Isolated helper turns stay fail-closed. --- src/adapters/cursor.ts | 16 ++- src/adapters/cursor/cursor-errors.ts | 11 ++ src/adapters/cursor/protobuf-request.ts | 20 +++- structure/providers/cursor.md | 2 + tests/providers/cursor/cursor-adapter.test.ts | 113 ++++++++++++++++++ tests/providers/cursor/cursor-blob.test.ts | 44 +++++++ tests/providers/cursor/cursor-errors.test.ts | 14 +++ 7 files changed, 218 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 867cca2bab..69b10d3f2a 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,7 +3,7 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; @@ -202,6 +202,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda let completedNormally = false; let lastTransport: { captured?: Uint8Array } | undefined; let emittedClientTool = false; + let sawIncompleteToolCall = 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 @@ -344,6 +345,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda }, }); for (const event of events) { + if (event.type === "error" && isCursorIncompleteToolCallMessage(event.message)) { + sawIncompleteToolCall = true; + } if (!guardsSettled()) { if (event.type === "text_delta") { guardHeld.push(event); @@ -529,6 +533,16 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } } } + // Incomplete-tool errors are streamed, not thrown. Do not retry this turn; remint so + // the next request does not reuse a Cursor conversation left waiting for mcpResult. + if (sawIncompleteToolCall && _parsed._cursorIsolateConversation !== true) { + if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); + debugProviderDiagnostic("cursor", "incomplete-tool-remint", { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }); + remintConversationId(request.conversationId); + } if ( request.checkpointInvalidationReason && request.checkpointInvalidationReason !== "missing_ref" diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index c06039e467..97174f9bf8 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -46,6 +46,17 @@ export class CursorStreamTruncatedError extends Error { } } +/** + * True when Cursor ended the stream with a client tool still open. The adapter fail-closes + * the current turn (no partial `tool_call_start`) and remints the conversation afterwards + * so the next turn does not resume a session left waiting for `mcpResult`. + */ +export function isCursorIncompleteToolCallMessage(value: unknown): boolean { + const message = typeof value === "string" ? value : errorMessage(value); + const lower = message.toLowerCase(); + return lower.includes("incomplete tool call") || lower.includes("tool call(s) left incomplete"); +} + /** * A cancel-shaped stream failure that WE did not request. `cancelCursorRun` is the only place * that cancels our own stream, and it sets `expectedClose` first, so a cancel arriving without it diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index c31815edad..b33c651838 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -75,6 +75,8 @@ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization"; export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192; /** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */ export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024; +/** Honest placeholder when native Composer history has a toolCall with no matching toolResult. */ +export const CURSOR_MISSING_TOOL_RESULT = "[missing tool_result for this tool_use in history]"; /** * Byte budget for the serialized arguments named inside ONE replayed tool-result envelope. The * invocation identifies the call; the result is the payload. Without an independent cap, a single @@ -1218,6 +1220,20 @@ function argBytes(value: unknown): Uint8Array { } } +function missingToolResultFor( + part: Extract, +): OcxToolResultMessage { + return { + role: "toolResult", + toolCallId: part.id, + toolName: part.name, + ...(part.namespace ? { toolNamespace: part.namespace } : {}), + content: CURSOR_MISSING_TOOL_RESULT, + isError: true, + timestamp: 0, + }; +} + function toolCallStep( part: Extract, requestScope: CursorBlobRequestScopeToken, @@ -1332,7 +1348,9 @@ function conversationTurns( const pendingToolCalls = new Map>(); const flush = () => { if (!current) return; - for (const part of pendingToolCalls.values()) current.steps.push(toolCallStep(part, requestScope)); + for (const part of pendingToolCalls.values()) { + current.steps.push(toolCallStep(part, requestScope, missingToolResultFor(part), codeMode)); + } turns.push(storeCursorBlob(toBinary(ConversationTurnStructureSchema, create(ConversationTurnStructureSchema, { turn: { case: "agentConversationTurn", diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 43e196d6a9..1121f6ba61 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -160,6 +160,8 @@ classifies as overflow. Coverage lives in `src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects, isolated helper/shadow requests and compaction remain fail-closed. Isolated requests neither consume the parent allowance nor invalidate its checkpoint. Eligible overflow checks refresh existing retention timestamps and LRU position even after the cap is exhausted, without allocating absent scopes. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. +An incomplete client-tool stream is fail-closed for the current turn: `finalizeTurnEvents` emits `Cursor stream ended with incomplete tool call(s)` and does not retry that send. After the error is streamed, eligible non-isolated turns remint the Cursor conversation id, persist the thread override, and invalidate the inherited checkpoint so the next turn does not resume a conversation left waiting for `mcpResult`. Isolated helper and compaction turns do not remint or donate that recovery to the parent. Native Composer replay synthesizes `[missing tool_result for this tool_use in history]` for unpaired `toolCallStep` history; external wire models skip native `mcpToolCall` replay, so conversation remint is their recovery path. + 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. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 9a03be37cd..6ca93c753c 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1207,3 +1207,116 @@ describe("Cursor overflow accounting across requests", () => { } }); }); + +const INCOMPLETE_TOOL_ERROR = + "Cursor stream ended with incomplete tool call(s): call_abc. Arguments may be truncated; the call was not committed."; + +describe("Cursor incomplete-tool conversation remint", () => { + test("remints after a streamed incomplete-tool error and persists the thread override", async () => { + clearCursorThreadContinuityForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + if (attempts === 1) { + yield { type: "error", message: INCOMPLETE_TOOL_ERROR } satisfies CursorServerMessage; + return; + } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + + const threadId = "incomplete-tool-remint-thread"; + const body: OcxParsedRequest = { + modelId: "cursor/grok-4.6", + context: { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + stream: false, + options: {}, + _cursorIdentityScope: "acct-incomplete-tool-remint", + _clientThreadId: threadId, + }; + + const firstEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => firstEvents.push(event)); + + expect(attempts).toBe(1); + expect(firstEvents).toEqual([ + { type: "error", message: INCOMPLETE_TOOL_ERROR }, + ]); + expect(body._cursorConversationId).toBeDefined(); + expect(body._cursorConversationId).not.toBe(seen[0]); + expect(lookupCursorThreadConversation(threadId, "acct-incomplete-tool-remint")).toBe(body._cursorConversationId); + + const secondEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => secondEvents.push(event)); + + expect(attempts).toBe(2); + expect(seen).toHaveLength(2); + expect(seen[1]).toBe(body._cursorConversationId); + expect(seen[1]).not.toBe(seen[0]); + expect(secondEvents.some(event => event.type === "done")).toBe(true); + }); + + test("isolated helpers do not remint or park a throwaway id on the parent thread", async () => { + clearCursorThreadContinuityForTests(); + clearCursorCheckpointsForTests(); + const seen: string[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run(request) { + seen.push(request.conversationId); + yield { type: "error", message: INCOMPLETE_TOOL_ERROR } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + + try { + const owner = "incomplete-tool-isolated-helper"; + const parentRef = commitCursorCheckpoint({ + conversationId: "cursor_parent_incomplete", + identityScope: "acct-incomplete-tool-remint", + modelId: "default", + checkpointBytes: toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["incomplete-isolation-fixture"], + })), + coveredMessageCount: 1, + }); + expect(parentRef).toBeDefined(); + + const helper: OcxParsedRequest = { + modelId: "cursor/grok-4.6", + context: { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + stream: false, + options: {}, + _cursorIsolateConversation: true, + _cursorConversationId: "cursor_parent_incomplete", + _cursorIdentityScope: "acct-incomplete-tool-remint", + _clientThreadId: owner, + _providerContinuation: { + cursor: { conversationId: "cursor_parent_incomplete", checkpointUsable: true, checkpointRef: parentRef }, + }, + }; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(helper, { headers: new Headers() }, event => events.push(event)); + + expect(seen).toHaveLength(1); + expect(events).toEqual([{ type: "error", message: INCOMPLETE_TOOL_ERROR }]); + expect(helper._cursorConversationId).toBe(seen[0]); + expect(getCursorCheckpoint(parentRef)?.ref).toBe(parentRef); + expect(lookupCursorThreadConversation(owner, "acct-incomplete-tool-remint")).toBeUndefined(); + } finally { + clearCursorThreadContinuityForTests(); + clearCursorCheckpointsForTests(); + } + }); +}); diff --git a/tests/providers/cursor/cursor-blob.test.ts b/tests/providers/cursor/cursor-blob.test.ts index 7df7693e18..912e05808c 100644 --- a/tests/providers/cursor/cursor-blob.test.ts +++ b/tests/providers/cursor/cursor-blob.test.ts @@ -37,6 +37,7 @@ import { CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT, CURSOR_ROUTING_LEVEL_PARAMETER_ID, + CURSOR_MISSING_TOOL_RESULT, encodeCursorRunRequest, prepareCursorRunRequest, } from "../../../src/adapters/cursor/protobuf-request"; @@ -840,6 +841,49 @@ describe("Cursor blob handshake", () => { expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); }); + test("native Composer unpaired tool calls replay with a missing-result placeholder", () => { + resetCursorCallIdProvenanceForTests(); + const local = encodeCursorCallId("ocxc1e_"); + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "user", content: "continue anyway" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "read_file", arguments: { path: "a.txt" } }], + }, + { role: "user", content: "continue anyway", timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const turnIds = run?.conversationState?.turns ?? []; + expect(turnIds).toHaveLength(1); + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnIds[0]!)); + expect(turn.turn.case).toBe("agentConversationTurn"); + const steps = turn.turn.value.steps; + expect(steps).toHaveLength(1); + const step = fromBinary(ConversationStepSchema, blobData(steps[0]!)); + expect(step.message.case).toBe("toolCall"); + const tool = step.message.value.tool; + expect(tool.case).toBe("mcpToolCall"); + if (tool.case === "mcpToolCall") { + expect(tool.value.args?.toolCallId).toBe("ocxc1e_"); + expect(tool.value.result?.result.case).toBe("success"); + if (tool.value.result?.result.case === "success") { + expect(tool.value.result.result.value.isError).toBe(true); + const content = tool.value.result.result.value.content[0]?.content; + expect(content?.case).toBe("text"); + if (content?.case === "text") expect(content.value.text).toBe(CURSOR_MISSING_TOOL_RESULT); + } + } + }); + test("native protobuf replay leaves an opaque escape lookalike byte-identical", () => { resetCursorCallIdProvenanceForTests(); const opaque = "ocxc1e_b2N4YzFf"; diff --git a/tests/providers/cursor/cursor-errors.test.ts b/tests/providers/cursor/cursor-errors.test.ts index 849659872f..73e7ec0602 100644 --- a/tests/providers/cursor/cursor-errors.test.ts +++ b/tests/providers/cursor/cursor-errors.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { classifyCursorError, isCursorBenignCancelError, + isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, safeCursorErrorMessage, } from "../../../src/adapters/cursor/cursor-errors"; @@ -231,3 +232,16 @@ describe("bare resource_exhausted size prior (devlog 260)", () => { }); }); }); + +describe("isCursorIncompleteToolCallMessage", () => { + test("matches streamed incomplete-tool errors and the unused truncation class", () => { + expect(isCursorIncompleteToolCallMessage( + "Cursor stream ended with incomplete tool call(s): call_abc. Arguments may be truncated; the call was not committed.", + )).toBe(true); + expect(isCursorIncompleteToolCallMessage( + "Cursor stream ended without terminating the turn; 1 tool call(s) left incomplete (call_abc) after 3 frame(s).", + )).toBe(true); + expect(isCursorIncompleteToolCallMessage("Cursor rate limit exceeded")).toBe(false); + expect(isCursorIncompleteToolCallMessage(new Error("Cursor stream ended with incomplete tool call(s): x"))).toBe(true); + }); +}); From 09d1e821d4bcac90a91d6fc7081d99ad322e30ab Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 18:01:45 +0900 Subject: [PATCH 2/2] fix(cursor): bound incomplete-tool conversation remints Keep compaction and isolated turns outside incomplete-tool recovery, cap rotations per retained thread scope, and share the producer message prefix with classification. Preserve the blob-test size ratchet by relocating the existing replay regression. Co-authored-by: MerryEcho --- src/adapters/cursor.ts | 39 ++- src/adapters/cursor/cursor-errors.ts | 6 +- src/adapters/cursor/protobuf-events.ts | 3 +- src/adapters/cursor/thread-continuity.ts | 67 +++++ structure/providers/cursor.md | 2 +- tests/providers/cursor/cursor-adapter.test.ts | 249 +++++++++++++++++- tests/providers/cursor/cursor-blob.test.ts | 44 ---- tests/providers/cursor/cursor-errors.test.ts | 14 + 8 files changed, 363 insertions(+), 61 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 69b10d3f2a..a8b518b90a 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -32,8 +32,11 @@ import { isDebugEnabled } from "../lib/debug-settings"; import { createAdapterTierMetadata } from "../providers/fastwire"; import { estimateTokens } from "../lib/token-estimate"; import { + clearCursorIncompleteToolRemint, + cursorIncompleteToolRemintScopeKey, cursorOverflowRemintScopeKey, markCursorOverflowSurfaced, + recordCursorIncompleteToolRemint, recordCursorOverflowRemint, rememberCursorThreadConversation, shouldSkipCursorOverflowRemint, @@ -533,15 +536,33 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } } } - // Incomplete-tool errors are streamed, not thrown. Do not retry this turn; remint so - // the next request does not reuse a Cursor conversation left waiting for mcpResult. - if (sawIncompleteToolCall && _parsed._cursorIsolateConversation !== true) { - if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); - debugProviderDiagnostic("cursor", "incomplete-tool-remint", { - wireModel: request.modelId, - conversationHash: request.conversationId.slice(0, 16), - }); - remintConversationId(request.conversationId); + const incompleteToolRemintScopeKey = + _parsed._cursorIsolateConversation !== true + && request.contextUsageStoreCheckpoints !== false + ? cursorIncompleteToolRemintScopeKey( + cursorClientThreadOwner(_parsed), + _parsed._cursorIdentityScope, + ) + : null; + // Incomplete-tool errors are streamed, not thrown. Do not retry this turn; rotate only + // the next turn's id. request-prepare currently isolates compaction, but adapter callers + // can bypass that upstream invariant, so checkpoint storage is the local isolation boundary. + if (sawIncompleteToolCall && incompleteToolRemintScopeKey) { + if (recordCursorIncompleteToolRemint(incompleteToolRemintScopeKey)) { + if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); + debugProviderDiagnostic("cursor", "incomplete-tool-remint", { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }); + remintConversationId(request.conversationId); + } else { + debugProviderDiagnostic("cursor", "incomplete-tool-remint-exhausted", { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }); + } + } else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) { + clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey); } if ( request.checkpointInvalidationReason diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index 97174f9bf8..e38fdb0ed1 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -46,6 +46,9 @@ export class CursorStreamTruncatedError extends Error { } } +export const CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX = + "Cursor stream ended with incomplete tool call(s):"; + /** * True when Cursor ended the stream with a client tool still open. The adapter fail-closes * the current turn (no partial `tool_call_start`) and remints the conversation afterwards @@ -54,7 +57,8 @@ export class CursorStreamTruncatedError extends Error { export function isCursorIncompleteToolCallMessage(value: unknown): boolean { const message = typeof value === "string" ? value : errorMessage(value); const lower = message.toLowerCase(); - return lower.includes("incomplete tool call") || lower.includes("tool call(s) left incomplete"); + return lower.includes(CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX.toLowerCase()) + || lower.includes("tool call(s) left incomplete"); } /** diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 1aa4f47eb0..1bc771a009 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -23,6 +23,7 @@ import { import { recordObservedCursorContextWindow } from "./discovery"; import type { CursorServerMessage } from "./types"; import type { TranslatorBudget } from "../../lib/translator-budget"; +import { CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX } from "./cursor-errors"; const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200; const DEFAULT_CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000; @@ -1458,7 +1459,7 @@ export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServe // Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit. for (const callId of openCallIds) state.translatorBudget?.closeCall(callId); state.openToolCalls.clear(); - return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }]; + return [{ type: "error", message: `${CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX} ${openIds}. Arguments may be truncated; the call was not committed.` }]; } const out: CursorServerMessage[] = []; if (!state.sawRealClientToolCall) { diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index fc57099f58..ed29fdc88a 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -158,3 +158,70 @@ export function cursorOverflowRemintCountForTests(): number { pruneOverflowRemints(now()); return overflowRemintByScope.size; } + +/** Max next-turn conversation-id rotations after incomplete client-tool streams per retained scope. */ +export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX = 3; +export const CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS = CURSOR_OVERFLOW_REMINT_TTL_MS; +export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES = CURSOR_OVERFLOW_REMINT_MAX_ENTRIES; + +type IncompleteToolRemintState = { + remintCount: number; + updatedAt: number; +}; + +const incompleteToolRemintByScope = 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); + } + } + while (incompleteToolRemintByScope.size > CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES) { + const oldest = incompleteToolRemintByScope.keys().next().value; + if (oldest === undefined) break; + incompleteToolRemintByScope.delete(oldest); + } +} + +/** Incomplete-tool and overflow recovery share ownership scope, but keep independent budgets. */ +export function cursorIncompleteToolRemintScopeKey( + threadOwner: string | undefined, + identityScope?: string, +): string | null { + return cursorOverflowRemintScopeKey(threadOwner, identityScope); +} + +/** 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; +} + +/** A clean turn replenishes this recovery without changing the overflow retry budget. */ +export function clearCursorIncompleteToolRemint(scopeKey: string): void { + incompleteToolRemintByScope.delete(scopeKey); +} + +export function clearCursorIncompleteToolRemintForTests(): void { + incompleteToolRemintByScope.clear(); +} + +export function cursorIncompleteToolRemintCountForTests(): number { + pruneIncompleteToolRemints(now()); + return incompleteToolRemintByScope.size; +} diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 1121f6ba61..f290a0edc1 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -160,7 +160,7 @@ classifies as overflow. Coverage lives in `src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects, isolated helper/shadow requests and compaction remain fail-closed. Isolated requests neither consume the parent allowance nor invalidate its checkpoint. Eligible overflow checks refresh existing retention timestamps and LRU position even after the cap is exhausted, without allocating absent scopes. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. -An incomplete client-tool stream is fail-closed for the current turn: `finalizeTurnEvents` emits `Cursor stream ended with incomplete tool call(s)` and does not retry that send. After the error is streamed, eligible non-isolated turns remint the Cursor conversation id, persist the thread override, and invalidate the inherited checkpoint so the next turn does not resume a conversation left waiting for `mcpResult`. Isolated helper and compaction turns do not remint or donate that recovery to the parent. Native Composer replay synthesizes `[missing tool_result for this tool_use in history]` for unpaired `toolCallStep` history; external wire models skip native `mcpToolCall` replay, so conversation remint is their recovery path. +An incomplete client-tool stream is fail-closed for the current turn: `finalizeTurnEvents` emits the prefix owned by `CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX` and does not retry that send. After the error is streamed, eligible non-isolated turns remint the Cursor conversation id, persist the thread override, and invalidate the inherited checkpoint so the next turn does not resume a conversation left waiting for `mcpResult`. `src/adapters/cursor/thread-continuity.ts` permits three such rotations per retained identity-scoped thread owner in a separate bounded counter; exhaustion keeps reusing the conversation and records an `incomplete-tool-remint-exhausted` diagnostic, while a clean completed turn clears that scope's counter. This allowance never consumes or replenishes the overflow resend budget. Isolated helper and compaction turns neither remint nor change the parent's allowance or checkpoint. Native Composer replay synthesizes `[missing tool_result for this tool_use in history]` for unpaired `toolCallStep` history; external wire models skip native `mcpToolCall` replay, so conversation remint is their recovery path. 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. diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 6ca93c753c..d09076a03d 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1,25 +1,48 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { createCursorAdapter as createCursorAdapterProduction, cursorExecDeniedMessage, } from "../../../src/adapters/cursor"; import { + clearCursorIncompleteToolRemint, + clearCursorIncompleteToolRemintForTests, clearCursorOverflowRemintForTests, clearCursorThreadContinuityForTests, + CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES, + cursorIncompleteToolRemintScopeKey, + cursorIncompleteToolRemintCountForTests, + cursorOverflowRemintScopeKey, lookupCursorThreadConversation, + markCursorOverflowSurfaced, + recordCursorIncompleteToolRemint, + recordCursorOverflowRemint, + rememberCursorThreadConversation, + shouldSkipCursorOverflowRemint, } from "../../../src/adapters/cursor/thread-continuity"; import { clearCursorCheckpointsForTests, commitCursorCheckpoint, getCursorCheckpoint, } from "../../../src/adapters/cursor/checkpoint-store"; -import { create, toBinary } from "@bufbuild/protobuf"; -import { ConversationStateStructureSchema } from "../../../src/adapters/cursor/gen/agent_pb"; +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { + AgentClientMessageSchema, + ConversationStateStructureSchema, + ConversationStepSchema, + ConversationTurnStructureSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; import type { CursorTransportFactoryInput } from "../../../src/adapters/cursor/transport"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; -import { CursorRootEnvelopeLimitError } from "../../../src/adapters/cursor/cursor-errors"; +import { CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX, CursorRootEnvelopeLimitError } from "../../../src/adapters/cursor/cursor-errors"; +import { getDebugLogEntries, resetDebugLogBufferForTests } from "../../../src/lib/debug-log-buffer"; +import { resetDebugSettingsForTests, setDebugSettings } from "../../../src/lib/debug-settings"; +import { encodeCursorCallId, resetCursorCallIdProvenanceForTests } from "../../../src/adapters/cursor/call-id"; +import { handleCursorNativeKv, resetCursorBlobStateForTests } from "../../../src/adapters/cursor/native-exec"; +import { CURSOR_MISSING_TOOL_RESULT, encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; const createCursorAdapter = (...args: Parameters) => withTestTranslatorBudget(createCursorAdapterProduction(...args)); @@ -1209,10 +1232,63 @@ describe("Cursor overflow accounting across requests", () => { }); const INCOMPLETE_TOOL_ERROR = - "Cursor stream ended with incomplete tool call(s): call_abc. Arguments may be truncated; the call was not committed."; + `${CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX} call_abc. Arguments may be truncated; the call was not committed.`; describe("Cursor incomplete-tool conversation remint", () => { + test("native Composer unpaired tool calls replay with a missing-result placeholder", () => { + resetCursorBlobStateForTests(); + resetCursorCallIdProvenanceForTests(); + const blobData = (blobId: Uint8Array): Uint8Array => { + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, + }))); + if (reply.message.case !== "kvClientMessage") return new Uint8Array(); + const kv = reply.message.value; + return kv.message.case === "getBlobResult" ? kv.message.value.blobData : new Uint8Array(); + }; + try { + const local = encodeCursorCallId("ocxc1e_"); + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "user", content: "continue anyway" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "read_file", arguments: { path: "a.txt" } }], + }, + { role: "user", content: "continue anyway", timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const turnIds = run?.conversationState?.turns ?? []; + expect(turnIds).toHaveLength(1); + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnIds[0]!)); + expect(turn.turn.case).toBe("agentConversationTurn"); + const step = fromBinary(ConversationStepSchema, blobData(turn.turn.value.steps[0]!)); + expect(step.message.case).toBe("toolCall"); + const tool = step.message.value.tool; + expect(tool.case).toBe("mcpToolCall"); + if (tool.case === "mcpToolCall" && tool.value.result?.result.case === "success") { + expect(tool.value.args?.toolCallId).toBe("ocxc1e_"); + expect(tool.value.result.result.value.isError).toBe(true); + const content = tool.value.result.result.value.content[0]?.content; + expect(content?.case).toBe("text"); + if (content?.case === "text") expect(content.value.text).toBe(CURSOR_MISSING_TOOL_RESULT); + } + } finally { + resetCursorBlobStateForTests(); + } + }); + test("remints after a streamed incomplete-tool error and persists the thread override", async () => { + clearCursorIncompleteToolRemintForTests(); clearCursorThreadContinuityForTests(); let attempts = 0; const seen: string[] = []; @@ -1266,7 +1342,170 @@ describe("Cursor incomplete-tool conversation remint", () => { expect(secondEvents.some(event => event.type === "done")).toBe(true); }); + test("compaction storage isolation preserves the stable thread override without relying on the isolate flag", async () => { + clearCursorIncompleteToolRemintForTests(); + clearCursorThreadContinuityForTests(); + const owner = "incomplete-tool-compaction"; + const identityScope = "acct-incomplete-tool-remint"; + const stableConversation = "cursor_parent_stable"; + rememberCursorThreadConversation(owner, stableConversation, identityScope); + const seen: string[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run(request) { + seen.push(request.conversationId); + yield { type: "error", message: INCOMPLETE_TOOL_ERROR } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + const compaction: OcxParsedRequest = { + modelId: "cursor/grok-4.6", + context: { messages: [{ role: "user", content: "summarize", timestamp: 1 }] }, + stream: false, + options: {}, + _compactionRequest: true, + _cursorConversationId: "cursor_compaction_turn", + _cursorIdentityScope: identityScope, + _clientThreadId: owner, + }; + + await adapter.runTurn?.(compaction, { headers: new Headers() }, () => {}); + + expect(compaction._cursorIsolateConversation).toBeUndefined(); + expect(seen).toEqual(["cursor_compaction_turn"]); + expect(compaction._cursorConversationId).toBe("cursor_compaction_turn"); + expect(lookupCursorThreadConversation(owner, identityScope)).toBe(stableConversation); + }); + + test("the fourth incomplete-tool truncation keeps the conversation and records exhaustion", async () => { + clearCursorIncompleteToolRemintForTests(); + clearCursorThreadContinuityForTests(); + resetDebugLogBufferForTests(); + setDebugSettings({ debug: true }); + const consoleError = spyOn(console, "error").mockImplementation(() => {}); + const seen: string[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run(request) { + seen.push(request.conversationId); + yield { type: "error", message: INCOMPLETE_TOOL_ERROR } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + const owner = "incomplete-tool-cap"; + const body: OcxParsedRequest = { + modelId: "cursor/grok-4.6", + context: { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + stream: false, + options: {}, + _cursorIdentityScope: "acct-incomplete-tool-remint", + _clientThreadId: owner, + }; + + try { + for (let truncation = 0; truncation < 3; truncation++) { + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + expect(body._cursorConversationId).not.toBe(seen.at(-1)); + } + const retainedConversation = body._cursorConversationId; + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + + expect(seen).toHaveLength(4); + expect(seen.at(-1)).toBe(retainedConversation); + expect(body._cursorConversationId).toBe(retainedConversation); + expect(lookupCursorThreadConversation(owner, "acct-incomplete-tool-remint")).toBe(retainedConversation); + expect(getDebugLogEntries().some(entry => entry.line.includes("[ocx:cursor:incomplete-tool-remint-exhausted]"))).toBe(true); + } finally { + consoleError.mockRestore(); + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); + clearCursorIncompleteToolRemintForTests(); + clearCursorThreadContinuityForTests(); + } + }); + + test("a clean completed turn replenishes the incomplete-tool remint budget", async () => { + clearCursorIncompleteToolRemintForTests(); + clearCursorThreadContinuityForTests(); + let incomplete = true; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run() { + if (incomplete) { + yield { type: "error", message: INCOMPLETE_TOOL_ERROR } satisfies CursorServerMessage; + } else { + yield { type: "done" } satisfies CursorServerMessage; + } + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + const body: OcxParsedRequest = { + modelId: "cursor/grok-4.6", + context: { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + stream: false, + options: {}, + _cursorIdentityScope: "acct-incomplete-tool-remint", + _clientThreadId: "incomplete-tool-clean-reset", + }; + + for (let truncation = 0; truncation < 3; truncation++) { + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + } + incomplete = false; + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + incomplete = true; + const beforeRecoveredTruncation = body._cursorConversationId; + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + + expect(body._cursorConversationId).not.toBe(beforeRecoveredTruncation); + }); + + test("incomplete-tool and overflow remint budgets do not consume or replenish each other", () => { + clearCursorIncompleteToolRemintForTests(); + clearCursorOverflowRemintForTests(); + const incompleteScope = cursorIncompleteToolRemintScopeKey("independent-remint-budgets", "acct-remint-budget"); + const overflowScope = cursorOverflowRemintScopeKey("independent-remint-budgets", "acct-remint-budget"); + expect(incompleteScope).toBe(overflowScope); + if (!incompleteScope || !overflowScope) throw new Error("stable thread owner must produce remint scopes"); + + for (let attempt = 0; attempt < 3; attempt++) { + expect(recordCursorIncompleteToolRemint(incompleteScope)).toBe(true); + } + expect(recordCursorIncompleteToolRemint(incompleteScope)).toBe(false); + expect(shouldSkipCursorOverflowRemint(overflowScope)).toBe(false); + markCursorOverflowSurfaced(overflowScope); + expect(recordCursorOverflowRemint(overflowScope)).toBe(true); + + clearCursorIncompleteToolRemintForTests(); + clearCursorOverflowRemintForTests(); + markCursorOverflowSurfaced(overflowScope); + for (let attempt = 0; attempt < 3; attempt++) { + expect(recordCursorOverflowRemint(overflowScope)).toBe(true); + } + expect(shouldSkipCursorOverflowRemint(overflowScope)).toBe(true); + expect(recordCursorIncompleteToolRemint(incompleteScope)).toBe(true); + clearCursorIncompleteToolRemint(incompleteScope); + expect(shouldSkipCursorOverflowRemint(overflowScope)).toBe(true); + }); + + test("bounds incomplete-tool remint state to the shared entry cap", () => { + clearCursorIncompleteToolRemintForTests(); + for (let index = 0; index < CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES + 20; index++) { + const scope = cursorIncompleteToolRemintScopeKey(`incomplete-retention-${index}`, "acct-remint-budget"); + if (!scope) throw new Error("stable thread owner must produce an incomplete-tool scope"); + expect(recordCursorIncompleteToolRemint(scope)).toBe(true); + } + expect(cursorIncompleteToolRemintCountForTests()).toBe(CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES); + clearCursorIncompleteToolRemintForTests(); + }); + test("isolated helpers do not remint or park a throwaway id on the parent thread", async () => { + clearCursorIncompleteToolRemintForTests(); clearCursorThreadContinuityForTests(); clearCursorCheckpointsForTests(); const seen: string[] = []; diff --git a/tests/providers/cursor/cursor-blob.test.ts b/tests/providers/cursor/cursor-blob.test.ts index 912e05808c..7df7693e18 100644 --- a/tests/providers/cursor/cursor-blob.test.ts +++ b/tests/providers/cursor/cursor-blob.test.ts @@ -37,7 +37,6 @@ import { CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT, CURSOR_ROUTING_LEVEL_PARAMETER_ID, - CURSOR_MISSING_TOOL_RESULT, encodeCursorRunRequest, prepareCursorRunRequest, } from "../../../src/adapters/cursor/protobuf-request"; @@ -841,49 +840,6 @@ describe("Cursor blob handshake", () => { expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); }); - test("native Composer unpaired tool calls replay with a missing-result placeholder", () => { - resetCursorCallIdProvenanceForTests(); - const local = encodeCursorCallId("ocxc1e_"); - const bytes = encodeCursorRunRequest({ - modelId: "composer-2.5", - conversationId: "c1", - system: ["You are helpful."], - messages: [{ role: "user", content: "continue anyway" }], - rawMessages: [ - { role: "user", content: "read a file", timestamp: 1 }, - { - role: "assistant", - model: "cursor/auto", - timestamp: 2, - content: [{ type: "toolCall", id: local, name: "read_file", arguments: { path: "a.txt" } }], - }, - { role: "user", content: "continue anyway", timestamp: 3 }, - ], - }); - const msg = fromBinary(AgentClientMessageSchema, bytes); - const run = msg.message.case === "runRequest" ? msg.message.value : undefined; - const turnIds = run?.conversationState?.turns ?? []; - expect(turnIds).toHaveLength(1); - const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnIds[0]!)); - expect(turn.turn.case).toBe("agentConversationTurn"); - const steps = turn.turn.value.steps; - expect(steps).toHaveLength(1); - const step = fromBinary(ConversationStepSchema, blobData(steps[0]!)); - expect(step.message.case).toBe("toolCall"); - const tool = step.message.value.tool; - expect(tool.case).toBe("mcpToolCall"); - if (tool.case === "mcpToolCall") { - expect(tool.value.args?.toolCallId).toBe("ocxc1e_"); - expect(tool.value.result?.result.case).toBe("success"); - if (tool.value.result?.result.case === "success") { - expect(tool.value.result.result.value.isError).toBe(true); - const content = tool.value.result.result.value.content[0]?.content; - expect(content?.case).toBe("text"); - if (content?.case === "text") expect(content.value.text).toBe(CURSOR_MISSING_TOOL_RESULT); - } - } - }); - test("native protobuf replay leaves an opaque escape lookalike byte-identical", () => { resetCursorCallIdProvenanceForTests(); const opaque = "ocxc1e_b2N4YzFf"; diff --git a/tests/providers/cursor/cursor-errors.test.ts b/tests/providers/cursor/cursor-errors.test.ts index 73e7ec0602..85fea30943 100644 --- a/tests/providers/cursor/cursor-errors.test.ts +++ b/tests/providers/cursor/cursor-errors.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { classifyCursorError, + CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX, isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, @@ -11,6 +12,7 @@ import { recordObservedCursorContextWindow, resetObservedCursorContextWindowsForTests, } from "../../../src/adapters/cursor/discovery"; +import { createCursorProtobufEventState, finalizeTurnEvents } from "../../../src/adapters/cursor/protobuf-events"; import { inferHttpStatusFromAdapterMessage } from "../../../src/lib/errors"; describe("classifyCursorError", () => { @@ -234,6 +236,18 @@ describe("bare resource_exhausted size prior (devlog 260)", () => { }); describe("isCursorIncompleteToolCallMessage", () => { + test("matches the message produced by finalizeTurnEvents", () => { + const state = createCursorProtobufEventState(); + state.openToolCalls.set("call_from_producer", { name: "read_file", args: "" }); + + const [event] = finalizeTurnEvents(state); + + expect(event?.type).toBe("error"); + if (event?.type !== "error") throw new Error("incomplete tool call must finalize as an error"); + expect(event.message.startsWith(CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX)).toBe(true); + expect(isCursorIncompleteToolCallMessage(event.message)).toBe(true); + }); + test("matches streamed incomplete-tool errors and the unused truncation class", () => { expect(isCursorIncompleteToolCallMessage( "Cursor stream ended with incomplete tool call(s): call_abc. Arguments may be truncated; the call was not committed.",