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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user