fix(session): recover cursor reasonless tool aborts

Scoped the Cursor server-execution marker gate to the stream-stall path so an unmarked client-side Cursor tool call (todo/MCP) followed by a reasonless abort recovers via its synthetic executed:false result instead of settling the turn.

Fixes #6668
This commit is contained in:
roboomp
2026-07-26 04:33:34 +00:00
parent 464eba46b3
commit 0988efbce6
2 changed files with 126 additions and 0 deletions
@@ -850,10 +850,18 @@ export class TurnRecovery {
if (!reasonlessAbort && !streamStall) return undefined;
if (reasonlessAbort && genericAbort) message.errorId = AIError.create(AIError.Flag.Abort);
// The Cursor server-execution marker gate applies only to the stream-stall
// path: an unmarked/unresolved Cursor block there means the server has not
// finished executing, so resuming would race it. A reasonless abort instead
// ends the turn and the agent loop pairs every un-run call (Cursor's unmarked
// `todo`/MCP blocks included) with a synthetic `executed: false` result, so
// the tool-result reconciliation below is the safety gate and the marker is
// irrelevant.
const resolvedToolCallIds: string[] = [];
for (const block of message.content) {
if (block.type !== "toolCall") continue;
if (
streamStall &&
message.provider === "cursor" &&
(!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true)
) {
@@ -1006,6 +1006,124 @@ describe("AgentSession retry delay cap", () => {
expect(retryEndEvents).toContainEqual(expect.objectContaining({ success: true, attempt: 1 }));
expect(lastAssistant(session).content).toContainEqual({ type: "text", text: "Recovered after Cursor stall" });
});
it("resumes a Cursor reasonless abort after an unmarked client-side tool call", async () => {
const model = createMockModel({
id: "composer-2.5",
provider: "cursor",
});
authStorage.setRuntimeApiKey("cursor", "cursor-test-key");
// Cursor emits `todo` client-side without the server-execution marker; a
// reasonless abort after it must still recover (issue #6668 review).
const toolCall: ToolCall = {
type: "toolCall",
id: "cursor-todo-1",
name: "todo",
arguments: { ops: [] },
};
let streamCalls = 0;
let resumedWithSyntheticResult = false;
const agent = new Agent({
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
streamFn: (_requestedModel, context, options) => {
streamCalls += 1;
if (streamCalls > 1) {
const matchingResult = context.messages.find(
message => message.role === "toolResult" && message.toolCallId === toolCall.id,
);
resumedWithSyntheticResult =
matchingResult?.role === "toolResult" &&
typeof matchingResult.details === "object" &&
matchingResult.details !== null &&
"executed" in matchingResult.details &&
matchingResult.details.executed === false;
model.push({ content: ["Recovered after Cursor reasonless abort"] });
return model.stream(model, context, options);
}
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const partial: AssistantMessage = {
role: "assistant",
content: [toolCall],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_start", contentIndex: 0, partial });
stream.push({
type: "toolcall_delta",
contentIndex: 0,
delta: JSON.stringify(toolCall.arguments),
partial,
});
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial });
stream.push({
type: "error",
reason: "aborted",
error: {
...partial,
stopReason: "aborted",
errorMessage: "Request was aborted",
},
});
});
return stream;
},
});
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.maxRetries": 1,
});
settings.setModelRole("default", `${model.provider}/${model.id}`);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
const retryStartEvents: AutoRetryStartEvent[] = [];
const retryEndEvents: AutoRetryEndEvent[] = [];
session.subscribe(event => {
if (event.type === "auto_retry_start") retryStartEvents.push(event);
if (event.type === "auto_retry_end") retryEndEvents.push(event);
});
await session.prompt("Update the todo list");
await session.waitForIdle();
expect(streamCalls).toBe(2);
expect(resumedWithSyntheticResult).toBe(true);
expect(
session.agent.state.messages.filter(
message => message.role === "toolResult" && message.toolCallId === toolCall.id,
),
).toHaveLength(1);
expect(retryStartEvents).toHaveLength(1);
expect(retryEndEvents).toContainEqual(expect.objectContaining({ success: true, attempt: 1 }));
expect(lastAssistant(session).content).toContainEqual({
type: "text",
text: "Recovered after Cursor reasonless abort",
});
});
it("retries a transient socket close after partial text and thinking", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");